version 2.2
This commit is contained in:
293
EVCSV2.2.json
Normal file
293
EVCSV2.2.json
Normal file
File diff suppressed because one or more lines are too long
217
decoupesurplus/BATTERY_CLOUD_LOCKS.js
Normal file
217
decoupesurplus/BATTERY_CLOUD_LOCKS.js
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
// === REASON-BASED LOCKS ===
|
||||||
|
const nextWindowCandidates = [
|
||||||
|
nextDailyOccurrenceMs(morningStartMin, nowMs),
|
||||||
|
nextDailyOccurrenceMs(hcMiddayStartMin, nowMs),
|
||||||
|
nextDailyOccurrenceMs(eveningStartMin, nowMs),
|
||||||
|
nextDailyOccurrenceMs(hcNightStartMin, nowMs),
|
||||||
|
].filter(v => Number.isFinite(v));
|
||||||
|
const nextAllowedWindowStartMs =
|
||||||
|
nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;
|
||||||
|
const blockActive = blockedUntilMs !== null && blockedUntilMs > nowMs + 1000;
|
||||||
|
const blockStillRelevant =
|
||||||
|
lastBlockReason === 'outside_window' ? profile === 'outside_window' :
|
||||||
|
lastBlockReason === 'probe_failed' ? (profile === 'morning_solar' || profile === 'midday_hc_or_solar') :
|
||||||
|
lastBlockReason === 'battery_protection_stop' ? (
|
||||||
|
batteryCurrentA < -batteryNeutralBandA || anyVebusLowBattery || vebusDcVoltageSoftLow
|
||||||
|
) :
|
||||||
|
lastBlockReason === 'grid_battery_guard' ? sourceMode === 'grid_hc_allowed' :
|
||||||
|
false;
|
||||||
|
const startBlockedByReason = blockActive && blockStillRelevant;
|
||||||
|
|
||||||
|
// === CLOUD DEBT ===
|
||||||
|
const sessionObservedActive = evcsChargingSwitchOn || controlLatch || chargePermissionNow;
|
||||||
|
const batteryNetPowerW = batteryCurrentA * batteryVoltageRef;
|
||||||
|
let cloudDebtNextKwh = cloudDebtPrevKwh;
|
||||||
|
if (sessionObservedActive) {
|
||||||
|
if (batteryNetPowerW < 0 && sourceMode !== 'grid_hc_allowed') {
|
||||||
|
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, cloudDebtMaxKwh * 4);
|
||||||
|
const cloudDebtMeaningful = cloudDebtNextKwh > CFG.cloudDebtMeaningfulThresholdKwh;
|
||||||
|
const cloudEventDischargingNow =
|
||||||
|
sessionObservedActive &&
|
||||||
|
sourceMode !== 'grid_hc_allowed' &&
|
||||||
|
batteryCurrentA < -Math.max(1, batteryNeutralBandA) &&
|
||||||
|
cloudDebtMeaningful;
|
||||||
|
const continuingCloudEvent =
|
||||||
|
cloudEventDischargingNow &&
|
||||||
|
cloudDebtStartedMsPrev !== null &&
|
||||||
|
cloudDebtPrevKwh > CFG.cloudDebtRecoveryThresholdKwh;
|
||||||
|
const enteringCloudDebt = cloudEventDischargingNow && !continuingCloudEvent;
|
||||||
|
const cloudDebtActive = cloudEventDischargingNow;
|
||||||
|
const cloudDebtStartedMs =
|
||||||
|
cloudEventDischargingNow
|
||||||
|
? (continuingCloudEvent ? cloudDebtStartedMsPrev : nowMs)
|
||||||
|
: null;
|
||||||
|
const cloudDebtDurationMinutes =
|
||||||
|
cloudDebtActive && cloudDebtStartedMs !== null
|
||||||
|
? Math.max(0, (nowMs - cloudDebtStartedMs) / 60000)
|
||||||
|
: 0;
|
||||||
|
const cloudToleranceOk =
|
||||||
|
cloudDebtNextKwh <= cloudDebtMaxKwh &&
|
||||||
|
(
|
||||||
|
!cloudDebtActive ||
|
||||||
|
cloudDebtDurationMinutes <= cloudToleranceMinutes
|
||||||
|
);
|
||||||
|
|
||||||
|
// === LEGACY SOLAR MODES KEPT ===
|
||||||
|
const batteryFullHarvestMode =
|
||||||
|
forecastModeEnabled &&
|
||||||
|
profile !== 'night_hc' &&
|
||||||
|
profile !== 'outside_window' &&
|
||||||
|
soc >= batteryFullSoc &&
|
||||||
|
todayRemainingKwh >= batteryFullForecastMinKwh &&
|
||||||
|
projectedEndSocAfterCharge >= (endDayGuardSoc - 2);
|
||||||
|
const forecastCommitMode =
|
||||||
|
forecastModeEnabled &&
|
||||||
|
profile !== 'night_hc' &&
|
||||||
|
profile !== 'outside_window' &&
|
||||||
|
soc >= forecastCommitSocFloor &&
|
||||||
|
todayRemainingKwh >= forecastCommitMinKwh &&
|
||||||
|
projectedEndSocAfterCharge >= endDayGuardSoc;
|
||||||
|
const batteryPriorityMode = !batteryFullHarvestMode && !forecastCommitMode;
|
||||||
|
|
||||||
|
// === BASE CONDITIONS ===
|
||||||
|
const commonSafetyStartOk =
|
||||||
|
canRunAtMinCurrent &&
|
||||||
|
!anyVebusOverload &&
|
||||||
|
!anyVebusLowBattery &&
|
||||||
|
!vebusDcVoltageSoftLow &&
|
||||||
|
!vebusAcOutSoftReached &&
|
||||||
|
evcsDeviceConnected &&
|
||||||
|
evcsVehicleConnected &&
|
||||||
|
!evcsModeIsAuto;
|
||||||
|
const commonSafetyHoldOk =
|
||||||
|
canRunAtMinCurrent &&
|
||||||
|
!anyVebusOverload &&
|
||||||
|
!anyVebusLowBattery &&
|
||||||
|
!vebusDcVoltageHardLow &&
|
||||||
|
evcsDeviceConnected &&
|
||||||
|
evcsVehicleConnected &&
|
||||||
|
!evcsModeIsAuto;
|
||||||
|
const profileSocStartFloor =
|
||||||
|
profile === 'morning_solar' ? morningSocMin :
|
||||||
|
profile === 'midday_hc_or_solar' ? middaySocMin :
|
||||||
|
profile === 'evening_high_soc' ? eveningSocStartMin :
|
||||||
|
socStartMin;
|
||||||
|
const profileSocHoldFloor =
|
||||||
|
profile === 'morning_solar' ? Math.max(socStopMin, morningSocMin - 10) :
|
||||||
|
profile === 'midday_hc_or_solar' ? Math.max(socStopMin, middaySocMin - 15) :
|
||||||
|
profile === 'evening_high_soc' ? eveningSocStopMin :
|
||||||
|
socStopMin;
|
||||||
|
const profileBatteryDischargeFloorA =
|
||||||
|
profile === 'morning_solar' ? morningBatteryMaxDischargeA :
|
||||||
|
profile === 'midday_hc_or_solar' ? middayBatteryMaxDischargeA :
|
||||||
|
profile === 'evening_high_soc' ? eveningBatteryMaxDischargeA :
|
||||||
|
batteryDischargeSoftA;
|
||||||
|
const solarStartOk =
|
||||||
|
sourceMode === 'solar_only' || sourceMode === 'solar_potential' &&
|
||||||
|
commonSafetyStartOk &&
|
||||||
|
soc >= profileSocStartFloor &&
|
||||||
|
batteryCurrentA >= profileBatteryDischargeFloorA &&
|
||||||
|
(sourceMode === 'solar_potential' || pvPowerW >= effectivePvStartMinW);
|
||||||
|
const solarHoldOk =
|
||||||
|
commonSafetyHoldOk &&
|
||||||
|
soc >= profileSocHoldFloor &&
|
||||||
|
batteryCurrentA >= profileBatteryDischargeFloorA &&
|
||||||
|
(pvPowerW >= pvHoldMinW || cloudToleranceOk);
|
||||||
|
const gridStartOk =
|
||||||
|
(sourceMode === 'grid_hc_allowed' || sourceMode === 'grid_forced') &&
|
||||||
|
commonSafetyStartOk &&
|
||||||
|
gridIsOn &&
|
||||||
|
batteryCurrentA >= gridBatterySoftA;
|
||||||
|
const gridHoldOk =
|
||||||
|
commonSafetyHoldOk &&
|
||||||
|
(sourceMode === 'grid_hc_allowed' || sourceMode === 'grid_forced') &&
|
||||||
|
gridIsOn &&
|
||||||
|
batteryCurrentA >= gridBatteryHardA;
|
||||||
|
const probeStartOk =
|
||||||
|
sourceMode === 'hybrid_probe' &&
|
||||||
|
commonSafetyStartOk &&
|
||||||
|
(morningProbeEligible || middayProbeEligible);
|
||||||
|
const probeHoldGuardOk =
|
||||||
|
commonSafetyHoldOk &&
|
||||||
|
batteryCurrentA >= profileBatteryDischargeFloorA;
|
||||||
|
const probeWindowActive =
|
||||||
|
sessionObservedActive &&
|
||||||
|
probeStartedMs !== null &&
|
||||||
|
minutesSinceProbeStart !== null &&
|
||||||
|
minutesSinceProbeStart < probeMinutes;
|
||||||
|
const probeElapsedReached =
|
||||||
|
sessionObservedActive &&
|
||||||
|
probeStartedMs !== null &&
|
||||||
|
minutesSinceProbeStart !== null &&
|
||||||
|
minutesSinceProbeStart >= probeMinutes;
|
||||||
|
const probeSuccessNow =
|
||||||
|
sessionObservedActive &&
|
||||||
|
probeStartedMs !== null &&
|
||||||
|
(
|
||||||
|
pvPowerW >= pvHoldMinW &&
|
||||||
|
batteryCurrentA >= Math.min(0, batteryDischargeSoftA)
|
||||||
|
);
|
||||||
|
const probeFailedNow = probeElapsedReached && !probeSuccessNow;
|
||||||
|
|
||||||
|
// === STEP LOGIC ===
|
||||||
|
const stepUpByBatteryReserve =
|
||||||
|
batteryPriorityMode &&
|
||||||
|
batteryCurrentA >= batteryReserveUpA;
|
||||||
|
const stepUpByForecastMode =
|
||||||
|
!batteryPriorityMode &&
|
||||||
|
(pvPowerW >= pvHoldMinW || pvPowerAvg10mW >= pvHoldMinW) &&
|
||||||
|
forecastBufferKwh >= -1 &&
|
||||||
|
projectedEndSocAfterCharge >= (endDayGuardSoc - 5) &&
|
||||||
|
cloudToleranceOk;
|
||||||
|
const solarShouldStepUp =
|
||||||
|
currentCooldownElapsed &&
|
||||||
|
currentReferenceA < physicalCapA &&
|
||||||
|
physicalCapA >= currentMinA &&
|
||||||
|
!vebusAcOutSoftReached &&
|
||||||
|
!vebusDcVoltageSoftLow &&
|
||||||
|
!houseLoadHighVsHeadroom &&
|
||||||
|
(stepUpByBatteryReserve || stepUpByForecastMode || pvPowerW >= (effectivePvStartMinW + 400));
|
||||||
|
const gridShouldStepUp =
|
||||||
|
currentCooldownElapsed &&
|
||||||
|
currentReferenceA < physicalCapA &&
|
||||||
|
physicalCapA >= currentMinA &&
|
||||||
|
!vebusAcOutSoftReached &&
|
||||||
|
!vebusDcVoltageSoftLow &&
|
||||||
|
batteryCurrentA >= gridBatterySoftA &&
|
||||||
|
!houseLoadHighVsHeadroom;
|
||||||
|
const solarShouldStepDownBattery =
|
||||||
|
batteryCurrentA <= batteryDischargeSoftA &&
|
||||||
|
currentCooldownElapsed &&
|
||||||
|
currentReferenceA > currentMinA;
|
||||||
|
const solarShouldStepDownCloudDebt =
|
||||||
|
!cloudToleranceOk &&
|
||||||
|
currentCooldownElapsed &&
|
||||||
|
currentReferenceA > currentMinA;
|
||||||
|
const gridShouldStepDownBattery =
|
||||||
|
batteryCurrentA < gridBatterySoftA &&
|
||||||
|
currentCooldownElapsed &&
|
||||||
|
currentReferenceA > currentMinA;
|
||||||
|
const shouldStepDownVebusPower =
|
||||||
|
vebusAcOutSoftReached &&
|
||||||
|
currentCooldownElapsed &&
|
||||||
|
currentReferenceA > currentMinA;
|
||||||
|
const shouldStepDownVebusVoltage =
|
||||||
|
vebusDcVoltageSoftLow &&
|
||||||
|
currentCooldownElapsed &&
|
||||||
|
currentReferenceA > currentMinA;
|
||||||
|
const batteryCurrentHardExceeded = batteryCurrentA <= batteryDischargeHardA;
|
||||||
|
|
||||||
|
// === HARD STOPS ===
|
||||||
|
const mustStopHard =
|
||||||
|
anyVebusOverload ||
|
||||||
|
anyVebusLowBattery ||
|
||||||
|
vebusDcVoltageHardLow ||
|
||||||
|
vebusAcOutHardReached ||
|
||||||
|
batteryCurrentHardExceeded;
|
||||||
|
const gridBatteryProtectionStop =
|
||||||
|
sessionObservedActive &&
|
||||||
|
sourceMode === 'grid_hc_allowed' &&
|
||||||
|
batteryCurrentA < gridBatteryHardA;
|
||||||
468
decoupesurplus/DECISION_ENGINE.js
Normal file
468
decoupesurplus/DECISION_ENGINE.js
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
// === DECISION INIT ===
|
||||||
|
let desiredCharging = false;
|
||||||
|
let desiredPermission = false;
|
||||||
|
let desiredLatch = false;
|
||||||
|
let desiredCurrentA = currentMinA;
|
||||||
|
let state = 'idle';
|
||||||
|
let currentDecision = 'hold';
|
||||||
|
let dynamicOk = false;
|
||||||
|
let mustStop = false;
|
||||||
|
let reason = [];
|
||||||
|
let setLastStartAt = false;
|
||||||
|
let setLastStopAt = false;
|
||||||
|
let setLastCurrentChangeAt = false;
|
||||||
|
let setCloudDebtStartedAt = false;
|
||||||
|
let setProbeStartedAt = false;
|
||||||
|
let notifyWanted = false;
|
||||||
|
let notifyTitle = 'Victron EVCS Surplus';
|
||||||
|
let notifyMessage = '';
|
||||||
|
let nextBlockReason = 'none';
|
||||||
|
let nextBlockedUntilStamp = now.stamp;
|
||||||
|
|
||||||
|
// === DECISION TREE (WITH STRATEGY LAYERS) ===
|
||||||
|
if (!controllerEnabled) {
|
||||||
|
desiredCharging = evcsChargingSwitchOn;
|
||||||
|
desiredPermission = chargePermissionNow;
|
||||||
|
desiredLatch = controlLatch;
|
||||||
|
desiredCurrentA = clamp(currentReferenceA || currentMinA, currentMinA, currentMaxAutoA);
|
||||||
|
state = 'controller_disabled';
|
||||||
|
reason.push("Contrôleur désactivé");
|
||||||
|
}
|
||||||
|
else if (isStopMode) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'stopped_user';
|
||||||
|
currentDecision = 'stop_stop_mode';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
nextBlockReason = 'manual_stop';
|
||||||
|
nextBlockedUntilStamp = now.stamp;
|
||||||
|
reason.push("Arrêt manuel via Stratégie STOP");
|
||||||
|
}
|
||||||
|
else if (evcsModeIsAuto) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'mode_auto_conflict';
|
||||||
|
currentDecision = 'stop_auto_mode_conflict';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
reason.push("Borne en mode Auto interne");
|
||||||
|
}
|
||||||
|
else if (!evcsDeviceConnected || !evcsVehicleConnected) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'vehicle_not_connected';
|
||||||
|
currentDecision = 'stop_not_connected';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
nextBlockReason = 'vehicle_not_connected';
|
||||||
|
nextBlockedUntilStamp = now.stamp;
|
||||||
|
reason.push("Véhicule ou borne non connecté");
|
||||||
|
}
|
||||||
|
else if (mustStopHard) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state =
|
||||||
|
anyVebusOverload ? 'vebus_overload_hard_stop' :
|
||||||
|
anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :
|
||||||
|
vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :
|
||||||
|
vebusAcOutHardReached ? 'vebus_ac_hard_stop' :
|
||||||
|
batteryCurrentHardExceeded ? 'battery_current_hard_stop' :
|
||||||
|
'hard_stop';
|
||||||
|
currentDecision = 'stop_hard';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
nextBlockReason = 'battery_protection_stop';
|
||||||
|
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 15 * 60000);
|
||||||
|
reason.push("Hard stop de protection");
|
||||||
|
}
|
||||||
|
else if (sessionObservedActive) {
|
||||||
|
if (enteringCloudDebt) {
|
||||||
|
setCloudDebtStartedAt = true;
|
||||||
|
}
|
||||||
|
if (gridBatteryProtectionStop) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'stop_grid_battery_guard';
|
||||||
|
currentDecision = 'stop';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
nextBlockReason = 'grid_battery_guard';
|
||||||
|
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 20 * 60000);
|
||||||
|
reason.push(`Arrêt HC réseau: batterie trop sollicitée (${round1(batteryCurrentA)}A)`);
|
||||||
|
}
|
||||||
|
else if (probeFailedNow) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'probe_failed_lock';
|
||||||
|
currentDecision = 'stop';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
nextBlockReason = 'probe_failed';
|
||||||
|
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + probeRetryMinutes * 60000);
|
||||||
|
reason.push("Mode sonde échoué");
|
||||||
|
}
|
||||||
|
else if (physicalCapA < currentMinA) {
|
||||||
|
if (minRunElapsed) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'stop_power_cap';
|
||||||
|
currentDecision = 'stop_power_cap';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
nextBlockReason = 'battery_protection_stop';
|
||||||
|
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000);
|
||||||
|
reason.push("Capacité système insuffisante");
|
||||||
|
} else {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'hold_min_run_power_cap';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
reason.push("Maintien jusqu'au temps mini ON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (probeWindowActive && !probeSuccessNow) {
|
||||||
|
if (!probeHoldGuardOk) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'probe_stop_guard';
|
||||||
|
currentDecision = 'stop';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
nextBlockReason = 'probe_failed';
|
||||||
|
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + probeRetryMinutes * 60000);
|
||||||
|
reason.push("Sonde interrompue: garde-fous non respectés");
|
||||||
|
} else {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = clamp(probeCurrentA, currentMinA, physicalCapA);
|
||||||
|
state = 'probe_hold';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = true;
|
||||||
|
mustStop = false;
|
||||||
|
reason.push(`Mode sonde actif à ${round1(desiredCurrentA)}A`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const activeSourceMode = probeSuccessNow ? 'solar_only' : sourceMode;
|
||||||
|
const holdConditionsOk =
|
||||||
|
activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced' ? gridHoldOk : // Ajout ici aussi
|
||||||
|
activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential' ? solarHoldOk :
|
||||||
|
probeHoldGuardOk;
|
||||||
|
if (activeSourceMode === 'blocked') {
|
||||||
|
if (minRunElapsed) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'stop_source_lost';
|
||||||
|
currentDecision = 'stop';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
nextBlockReason = profile === 'outside_window' ? 'outside_window' : 'source_unavailable';
|
||||||
|
nextBlockedUntilStamp =
|
||||||
|
profile === 'outside_window' && nextAllowedWindowStartMs !== null
|
||||||
|
? formatParisStampFromMs(nextAllowedWindowStartMs)
|
||||||
|
: formatParisStampFromMs(nowMs + minStopMinutes * 60000);
|
||||||
|
reason.push("Source autorisée perdue");
|
||||||
|
} else {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA);
|
||||||
|
state = 'hold_min_run_source_lost';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
reason.push("Source perdue mais maintien jusqu'au mini ON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!holdConditionsOk) {
|
||||||
|
if (minRunElapsed) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'stop_conditions_lost';
|
||||||
|
currentDecision = 'stop';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
nextBlockReason = 'battery_protection_stop';
|
||||||
|
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000);
|
||||||
|
reason.push("Conditions de maintien perdues");
|
||||||
|
} else {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA);
|
||||||
|
state = 'hold_min_run';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = true;
|
||||||
|
mustStop = false;
|
||||||
|
reason.push("Maintien jusqu'au temps mini ON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if ((currentReferenceA > physicalCapA) && currentCooldownElapsed) {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = Math.max(
|
||||||
|
currentMinA,
|
||||||
|
Math.min(currentReferenceA - Math.max(currentStepDownA, 2), physicalCapA)
|
||||||
|
);
|
||||||
|
state = 'charging_step_down_system_cap';
|
||||||
|
currentDecision = 'step_down';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Réduction pour respecter la capacité système");
|
||||||
|
}
|
||||||
|
else if (shouldStepDownVebusPower) {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = Math.max(
|
||||||
|
currentMinA,
|
||||||
|
Math.min(currentReferenceA - Math.max(currentStepDownA + 1, 3), physicalCapA)
|
||||||
|
);
|
||||||
|
state = 'charging_step_down_vebus_power';
|
||||||
|
currentDecision = 'step_down_fast';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Baisse rapide: puissance VE.Bus élevée");
|
||||||
|
}
|
||||||
|
else if (shouldStepDownVebusVoltage) {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = Math.max(
|
||||||
|
currentMinA,
|
||||||
|
Math.min(currentReferenceA - Math.max(currentStepDownA + 1, 3), physicalCapA)
|
||||||
|
);
|
||||||
|
state = 'charging_step_down_dc_voltage';
|
||||||
|
currentDecision = 'step_down_fast';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Baisse rapide: tension DC basse");
|
||||||
|
}
|
||||||
|
else if (activeSourceMode === 'grid_hc_allowed' && gridShouldStepDownBattery) {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = Math.max(
|
||||||
|
currentMinA,
|
||||||
|
Math.min(currentReferenceA - currentStepDownA, physicalCapA)
|
||||||
|
);
|
||||||
|
state = 'charging_step_down_grid_guard';
|
||||||
|
currentDecision = 'step_down';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Réduction HC: la batterie commence à participer");
|
||||||
|
}
|
||||||
|
else if (activeSourceMode === 'solar_only' && solarShouldStepDownCloudDebt) {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = Math.max(
|
||||||
|
currentMinA,
|
||||||
|
Math.min(currentReferenceA - Math.max(currentStepDownA, 2), physicalCapA)
|
||||||
|
);
|
||||||
|
state = 'charging_step_down_cloud_debt';
|
||||||
|
currentDecision = 'step_down';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Réduction: dette nuage trop élevée");
|
||||||
|
}
|
||||||
|
else if (activeSourceMode === 'solar_only' && solarShouldStepDownBattery) {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = Math.max(
|
||||||
|
currentMinA,
|
||||||
|
Math.min(currentReferenceA - currentStepDownA, physicalCapA)
|
||||||
|
);
|
||||||
|
state = 'charging_step_down_battery';
|
||||||
|
currentDecision = 'step_down';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Réduction: batterie trop sollicitée");
|
||||||
|
}
|
||||||
|
else if (activeSourceMode === 'grid_hc_allowed' && gridShouldStepUp) {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = clamp(currentReferenceA + currentStepUpA, currentMinA, physicalCapA);
|
||||||
|
state = 'charging_step_up_grid_hc';
|
||||||
|
currentDecision = 'step_up';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Hausse prudente du courant en HC réseau");
|
||||||
|
}
|
||||||
|
else if (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') { // Potentiel autorise step up aussi
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = clamp(currentReferenceA + currentStepUpA, currentMinA, physicalCapA);
|
||||||
|
state = probeSuccessNow
|
||||||
|
? 'probe_success_step_up'
|
||||||
|
: (batteryPriorityMode ? 'charging_step_up_battery' : 'charging_step_up_forecast');
|
||||||
|
currentDecision = 'step_up';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Hausse prudente du courant");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA);
|
||||||
|
state =
|
||||||
|
probeSuccessNow ? 'probe_success_hold' :
|
||||||
|
activeSourceMode === 'grid_hc_allowed' ? 'charging_hold_grid_hc' :
|
||||||
|
cloudDebtActive ? 'charging_hold_cloud_tolerance' :
|
||||||
|
'charging_hold';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = true;
|
||||||
|
mustStop = false;
|
||||||
|
reason.push("Maintien du courant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const startConditionsOk =
|
||||||
|
(sourceMode === 'grid_hc_allowed' || sourceMode === 'grid_forced') ? gridStartOk : // Ajout de 'grid_forced'
|
||||||
|
sourceMode === 'solar_only' || sourceMode === 'solar_potential' ? solarStartOk : // Potentiel autorise démarrage aussi
|
||||||
|
sourceMode === 'hybrid_probe' ? probeStartOk :
|
||||||
|
false;
|
||||||
|
if (profile === 'outside_window') {
|
||||||
|
// outside_window n'est plus un mur absolu en mode force ou avec potentiel
|
||||||
|
if (sourceMode === 'solar_potential' || isForceMode) {
|
||||||
|
// Continue à chercher startConditionsOk...
|
||||||
|
} else {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'blocked_outside_window';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
nextBlockReason = 'outside_window';
|
||||||
|
nextBlockedUntilStamp =
|
||||||
|
nextAllowedWindowStartMs !== null
|
||||||
|
? formatParisStampFromMs(nextAllowedWindowStartMs)
|
||||||
|
: now.stamp;
|
||||||
|
reason.push("Hors fenêtre autorisée (pas de potentiel confirmé ni forcé)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!canRunAtMinCurrent) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'blocked_by_capacity_min_current';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
nextBlockReason = 'battery_protection_stop';
|
||||||
|
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000);
|
||||||
|
reason.push("Capacité insuffisante pour courant mini");
|
||||||
|
}
|
||||||
|
else if (sourceMode === 'blocked') {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = profile === 'night_hc' ? 'night_no_grid_hc_lock' : 'idle_wait_source';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
nextBlockReason = profile === 'night_hc' ? 'outside_window' : 'source_unavailable';
|
||||||
|
nextBlockedUntilStamp =
|
||||||
|
profile === 'night_hc' && nextAllowedWindowStartMs !== null
|
||||||
|
? formatParisStampFromMs(nextAllowedWindowStartMs)
|
||||||
|
: formatParisStampFromMs(nowMs + 15 * 60000);
|
||||||
|
reason.push(profile === 'night_hc' ? "Nuit sans autorisation HC réseau" : "Source non autorisée ou insuffisante");
|
||||||
|
}
|
||||||
|
else if (startBlockedByReason) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'wait_reason_lock';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
nextBlockReason = lastBlockReason;
|
||||||
|
nextBlockedUntilStamp = blockedUntilAt || now.stamp;
|
||||||
|
reason.push(`Blocage anti-cliquetis: ${lastBlockReason}`);
|
||||||
|
}
|
||||||
|
else if (!startConditionsOk) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'idle_wait_conditions';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
reason.push("Conditions de démarrage non réunies");
|
||||||
|
}
|
||||||
|
else if (!minStopElapsed) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'wait_min_stop';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
reason.push("Temps mini OFF non écoulé");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = sourceMode === 'hybrid_probe'
|
||||||
|
? clamp(probeCurrentA, currentMinA, physicalCapA)
|
||||||
|
: currentMinA;
|
||||||
|
state =
|
||||||
|
sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :
|
||||||
|
sourceMode === 'hybrid_probe' ? 'probe_start_6a' :
|
||||||
|
sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' : // NOUVEAU STATE
|
||||||
|
batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :
|
||||||
|
forecastCommitMode ? 'start_min_current_forecast_commit' :
|
||||||
|
'start_min_current';
|
||||||
|
currentDecision = 'start';
|
||||||
|
dynamicOk = true;
|
||||||
|
mustStop = false;
|
||||||
|
nextBlockReason = 'none'; // Clean blocking logic: set reason to none on start
|
||||||
|
nextBlockedUntilStamp = now.stamp;
|
||||||
|
setProbeStartedAt = sourceMode === 'hybrid_probe';
|
||||||
|
reason.push(
|
||||||
|
sourceMode === 'grid_hc_allowed'
|
||||||
|
? "Démarrage en fenêtre HC réseau"
|
||||||
|
: sourceMode === 'hybrid_probe'
|
||||||
|
? "Démarrage en mode sonde"
|
||||||
|
: sourceMode === 'solar_potential'
|
||||||
|
? "Démarrage par potentiel solaire confirmé (SOC>=85%)" // NOUVEAU REASON TEXT
|
||||||
|
: batteryFullHarvestMode
|
||||||
|
? "Démarrage harvest solaire"
|
||||||
|
: forecastCommitMode
|
||||||
|
? "Démarrage forecast commit"
|
||||||
|
: "Démarrage prudent à courant mini"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
decoupesurplus/DECISION_ENGINE_4.1.js
Normal file
155
decoupesurplus/DECISION_ENGINE_4.1.js
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
// === DECISION INIT ===
|
||||||
|
let desiredCharging = false;
|
||||||
|
let desiredPermission = false;
|
||||||
|
let desiredLatch = false;
|
||||||
|
let desiredCurrentA = currentMinA;
|
||||||
|
let state = 'idle';
|
||||||
|
let currentDecision = 'hold';
|
||||||
|
let dynamicOk = false;
|
||||||
|
let mustStop = false;
|
||||||
|
let reason = [];
|
||||||
|
let setLastStartAt = false;
|
||||||
|
let setLastStopAt = false;
|
||||||
|
let setLastCurrentChangeAt = false;
|
||||||
|
let setCloudDebtStartedAt = false;
|
||||||
|
let setProbeStartedAt = false;
|
||||||
|
let notifyWanted = false;
|
||||||
|
let notifyTitle = 'Victron EVCS Surplus';
|
||||||
|
let notifyMessage = '';
|
||||||
|
let nextBlockReason = 'none';
|
||||||
|
let nextBlockedUntilStamp = now.stamp;
|
||||||
|
|
||||||
|
// === DECISION TREE (WITH STRATEGY LAYERS) ===
|
||||||
|
if (!controllerEnabled) {
|
||||||
|
desiredCharging = evcsChargingSwitchOn;
|
||||||
|
desiredPermission = chargePermissionNow;
|
||||||
|
desiredLatch = controlLatch;
|
||||||
|
desiredCurrentA = clamp(currentReferenceA || currentMinA, currentMinA, currentMaxAutoA);
|
||||||
|
state = 'controller_disabled';
|
||||||
|
reason.push("Contrôleur désactivé");
|
||||||
|
}
|
||||||
|
else if (isStopMode) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'stopped_user';
|
||||||
|
currentDecision = 'stop_stop_mode';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
nextBlockReason = 'manual_stop';
|
||||||
|
nextBlockedUntilStamp = now.stamp;
|
||||||
|
reason.push("Arrêt manuel via Stratégie STOP");
|
||||||
|
}
|
||||||
|
else if (evcsModeIsAuto) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'mode_auto_conflict';
|
||||||
|
currentDecision = 'stop_auto_mode_conflict';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
reason.push("Borne en mode Auto interne");
|
||||||
|
}
|
||||||
|
else if (!evcsDeviceConnected || !evcsVehicleConnected) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'vehicle_not_connected';
|
||||||
|
currentDecision = 'stop_not_connected';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
nextBlockReason = 'vehicle_not_connected';
|
||||||
|
nextBlockedUntilStamp = now.stamp;
|
||||||
|
reason.push("Véhicule ou borne non connecté");
|
||||||
|
}
|
||||||
|
else if (mustStopHard) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state =
|
||||||
|
anyVebusOverload ? 'vebus_overload_hard_stop' :
|
||||||
|
anyVebusLowBattery ? 'vebus_low_battery_hard_stop' :
|
||||||
|
vebusDcVoltageHardLow ? 'vebus_dc_voltage_hard_stop' :
|
||||||
|
vebusAcOutHardReached ? 'vebus_ac_hard_stop' :
|
||||||
|
batteryCurrentHardExceeded ? 'battery_current_hard_stop' :
|
||||||
|
'hard_stop';
|
||||||
|
currentDecision = 'stop_hard';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
nextBlockReason = 'battery_protection_stop';
|
||||||
|
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 15 * 60000);
|
||||||
|
reason.push("Hard stop de protection");
|
||||||
|
}
|
||||||
|
else if (sessionObservedActive) {
|
||||||
|
if (enteringCloudDebt) {
|
||||||
|
setCloudDebtStartedAt = true;
|
||||||
|
}
|
||||||
|
if (gridBatteryProtectionStop) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'stop_grid_battery_guard';
|
||||||
|
currentDecision = 'stop';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
nextBlockReason = 'grid_battery_guard';
|
||||||
|
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 20 * 60000);
|
||||||
|
reason.push(`Arrêt HC réseau: batterie trop sollicitée (${round1(batteryCurrentA)}A)`);
|
||||||
|
}
|
||||||
|
else if (probeFailedNow) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'probe_failed_lock';
|
||||||
|
currentDecision = 'stop';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
nextBlockReason = 'probe_failed';
|
||||||
|
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + probeRetryMinutes * 60000);
|
||||||
|
reason.push("Mode sonde échoué");
|
||||||
|
}
|
||||||
|
else if (physicalCapA < currentMinA) {
|
||||||
|
if (minRunElapsed) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'stop_power_cap';
|
||||||
|
currentDecision = 'stop_power_cap';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
nextBlockReason = 'battery_protection_stop';
|
||||||
|
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000);
|
||||||
|
reason.push("Capacité système insuffisante");
|
||||||
|
} else {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'hold_min_run_power_cap';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
reason.push("Maintien jusqu'au temps mini ON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (probeWindowActive && !probeSuccessNow) {
|
||||||
|
if (!probeHoldGuardOk) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'probe_stop_guard';
|
||||||
|
currentDecision = 'stop';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
nextBlockReason = 'probe_failed';
|
||||||
|
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + probeRetryMinutes * 60000);
|
||||||
|
reason.push("Sonde interrompue: garde-fous non respectés");
|
||||||
|
} else {
|
||||||
155
decoupesurplus/DECISION_ENGINE_4.2.js
Normal file
155
decoupesurplus/DECISION_ENGINE_4.2.js
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = clamp(probeCurrentA, currentMinA, physicalCapA);
|
||||||
|
state = 'probe_hold';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = true;
|
||||||
|
mustStop = false;
|
||||||
|
reason.push(`Mode sonde actif à ${round1(desiredCurrentA)}A`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const activeSourceMode = probeSuccessNow ? 'solar_only' : sourceMode;
|
||||||
|
const holdConditionsOk =
|
||||||
|
activeSourceMode === 'grid_hc_allowed' || activeSourceMode === 'grid_forced' ? gridHoldOk : // Ajout ici aussi
|
||||||
|
activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential' ? solarHoldOk :
|
||||||
|
probeHoldGuardOk;
|
||||||
|
if (activeSourceMode === 'blocked') {
|
||||||
|
if (minRunElapsed) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'stop_source_lost';
|
||||||
|
currentDecision = 'stop';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
nextBlockReason = profile === 'outside_window' ? 'outside_window' : 'source_unavailable';
|
||||||
|
nextBlockedUntilStamp =
|
||||||
|
profile === 'outside_window' && nextAllowedWindowStartMs !== null
|
||||||
|
? formatParisStampFromMs(nextAllowedWindowStartMs)
|
||||||
|
: formatParisStampFromMs(nowMs + minStopMinutes * 60000);
|
||||||
|
reason.push("Source autorisée perdue");
|
||||||
|
} else {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA);
|
||||||
|
state = 'hold_min_run_source_lost';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
reason.push("Source perdue mais maintien jusqu'au mini ON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!holdConditionsOk) {
|
||||||
|
if (minRunElapsed) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'stop_conditions_lost';
|
||||||
|
currentDecision = 'stop';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = true;
|
||||||
|
nextBlockReason = 'battery_protection_stop';
|
||||||
|
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000);
|
||||||
|
reason.push("Conditions de maintien perdues");
|
||||||
|
} else {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA);
|
||||||
|
state = 'hold_min_run';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = true;
|
||||||
|
mustStop = false;
|
||||||
|
reason.push("Maintien jusqu'au temps mini ON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if ((currentReferenceA > physicalCapA) && currentCooldownElapsed) {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = Math.max(
|
||||||
|
currentMinA,
|
||||||
|
Math.min(currentReferenceA - Math.max(currentStepDownA, 2), physicalCapA)
|
||||||
|
);
|
||||||
|
state = 'charging_step_down_system_cap';
|
||||||
|
currentDecision = 'step_down';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Réduction pour respecter la capacité système");
|
||||||
|
}
|
||||||
|
else if (shouldStepDownVebusPower) {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = Math.max(
|
||||||
|
currentMinA,
|
||||||
|
Math.min(currentReferenceA - Math.max(currentStepDownA + 1, 3), physicalCapA)
|
||||||
|
);
|
||||||
|
state = 'charging_step_down_vebus_power';
|
||||||
|
currentDecision = 'step_down_fast';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Baisse rapide: puissance VE.Bus élevée");
|
||||||
|
}
|
||||||
|
else if (shouldStepDownVebusVoltage) {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = Math.max(
|
||||||
|
currentMinA,
|
||||||
|
Math.min(currentReferenceA - Math.max(currentStepDownA + 1, 3), physicalCapA)
|
||||||
|
);
|
||||||
|
state = 'charging_step_down_dc_voltage';
|
||||||
|
currentDecision = 'step_down_fast';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Baisse rapide: tension DC basse");
|
||||||
|
}
|
||||||
|
else if (activeSourceMode === 'grid_hc_allowed' && gridShouldStepDownBattery) {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = Math.max(
|
||||||
|
currentMinA,
|
||||||
|
Math.min(currentReferenceA - currentStepDownA, physicalCapA)
|
||||||
|
);
|
||||||
|
state = 'charging_step_down_grid_guard';
|
||||||
|
currentDecision = 'step_down';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Réduction HC: la batterie commence à participer");
|
||||||
|
}
|
||||||
|
else if (activeSourceMode === 'solar_only' && solarShouldStepDownCloudDebt) {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = Math.max(
|
||||||
|
currentMinA,
|
||||||
|
Math.min(currentReferenceA - Math.max(currentStepDownA, 2), physicalCapA)
|
||||||
|
);
|
||||||
|
state = 'charging_step_down_cloud_debt';
|
||||||
|
currentDecision = 'step_down';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Réduction: dette nuage trop élevée");
|
||||||
|
}
|
||||||
|
else if (activeSourceMode === 'solar_only' && solarShouldStepDownBattery) {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = Math.max(
|
||||||
|
currentMinA,
|
||||||
|
Math.min(currentReferenceA - currentStepDownA, physicalCapA)
|
||||||
|
);
|
||||||
|
state = 'charging_step_down_battery';
|
||||||
|
currentDecision = 'step_down';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Réduction: batterie trop sollicitée");
|
||||||
|
}
|
||||||
|
else if (activeSourceMode === 'grid_hc_allowed' && gridShouldStepUp) {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = clamp(currentReferenceA + currentStepUpA, currentMinA, physicalCapA);
|
||||||
|
state = 'charging_step_up_grid_hc';
|
||||||
|
currentDecision = 'step_up';
|
||||||
|
dynamicOk = true;
|
||||||
156
decoupesurplus/DECISION_ENGINE_4.3.js
Normal file
156
decoupesurplus/DECISION_ENGINE_4.3.js
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
}
|
||||||
|
else if (activeSourceMode === 'solar_only' || activeSourceMode === 'solar_potential') { // Potentiel autorise step up aussi
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = clamp(currentReferenceA + currentStepUpA, currentMinA, physicalCapA);
|
||||||
|
state = probeSuccessNow
|
||||||
|
? 'probe_success_step_up'
|
||||||
|
: (batteryPriorityMode ? 'charging_step_up_battery' : 'charging_step_up_forecast');
|
||||||
|
currentDecision = 'step_up';
|
||||||
|
dynamicOk = true;
|
||||||
|
reason.push("Hausse prudente du courant");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = clamp(Math.min(currentReferenceA, physicalCapA), currentMinA, physicalCapA);
|
||||||
|
state =
|
||||||
|
probeSuccessNow ? 'probe_success_hold' :
|
||||||
|
activeSourceMode === 'grid_hc_allowed' ? 'charging_hold_grid_hc' :
|
||||||
|
cloudDebtActive ? 'charging_hold_cloud_tolerance' :
|
||||||
|
'charging_hold';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = true;
|
||||||
|
mustStop = false;
|
||||||
|
reason.push("Maintien du courant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const startConditionsOk =
|
||||||
|
(sourceMode === 'grid_hc_allowed' || sourceMode === 'grid_forced') ? gridStartOk : // Ajout de 'grid_forced'
|
||||||
|
sourceMode === 'solar_only' || sourceMode === 'solar_potential' ? solarStartOk : // Potentiel autorise démarrage aussi
|
||||||
|
sourceMode === 'hybrid_probe' ? probeStartOk :
|
||||||
|
false;
|
||||||
|
if (profile === 'outside_window') {
|
||||||
|
// outside_window n'est plus un mur absolu en mode force ou avec potentiel
|
||||||
|
if (sourceMode === 'solar_potential' || isForceMode) {
|
||||||
|
// Continue à chercher startConditionsOk...
|
||||||
|
} else {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'blocked_outside_window';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
nextBlockReason = 'outside_window';
|
||||||
|
nextBlockedUntilStamp =
|
||||||
|
nextAllowedWindowStartMs !== null
|
||||||
|
? formatParisStampFromMs(nextAllowedWindowStartMs)
|
||||||
|
: now.stamp;
|
||||||
|
reason.push("Hors fenêtre autorisée (pas de potentiel confirmé ni forcé)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!canRunAtMinCurrent) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'blocked_by_capacity_min_current';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
nextBlockReason = 'battery_protection_stop';
|
||||||
|
nextBlockedUntilStamp = formatParisStampFromMs(nowMs + 10 * 60000);
|
||||||
|
reason.push("Capacité insuffisante pour courant mini");
|
||||||
|
}
|
||||||
|
else if (sourceMode === 'blocked') {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = profile === 'night_hc' ? 'night_no_grid_hc_lock' : 'idle_wait_source';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
nextBlockReason = profile === 'night_hc' ? 'outside_window' : 'source_unavailable';
|
||||||
|
nextBlockedUntilStamp =
|
||||||
|
profile === 'night_hc' && nextAllowedWindowStartMs !== null
|
||||||
|
? formatParisStampFromMs(nextAllowedWindowStartMs)
|
||||||
|
: formatParisStampFromMs(nowMs + 15 * 60000);
|
||||||
|
reason.push(profile === 'night_hc' ? "Nuit sans autorisation HC réseau" : "Source non autorisée ou insuffisante");
|
||||||
|
}
|
||||||
|
else if (startBlockedByReason) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'wait_reason_lock';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
nextBlockReason = lastBlockReason;
|
||||||
|
nextBlockedUntilStamp = blockedUntilAt || now.stamp;
|
||||||
|
reason.push(`Blocage anti-cliquetis: ${lastBlockReason}`);
|
||||||
|
}
|
||||||
|
else if (!startConditionsOk) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'idle_wait_conditions';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
reason.push("Conditions de démarrage non réunies");
|
||||||
|
}
|
||||||
|
else if (!minStopElapsed) {
|
||||||
|
desiredCharging = false;
|
||||||
|
desiredPermission = false;
|
||||||
|
desiredLatch = false;
|
||||||
|
desiredCurrentA = currentMinA;
|
||||||
|
state = 'wait_min_stop';
|
||||||
|
currentDecision = 'hold';
|
||||||
|
dynamicOk = false;
|
||||||
|
mustStop = false;
|
||||||
|
reason.push("Temps mini OFF non écoulé");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
desiredCharging = true;
|
||||||
|
desiredPermission = true;
|
||||||
|
desiredLatch = true;
|
||||||
|
desiredCurrentA = sourceMode === 'hybrid_probe'
|
||||||
|
? clamp(probeCurrentA, currentMinA, physicalCapA)
|
||||||
|
: currentMinA;
|
||||||
|
state =
|
||||||
|
sourceMode === 'grid_hc_allowed' ? 'start_grid_hc_min_current' :
|
||||||
|
sourceMode === 'hybrid_probe' ? 'probe_start_6a' :
|
||||||
|
sourceMode === 'solar_potential' ? 'start_forecast_solar_potential' : // NOUVEAU STATE
|
||||||
|
batteryFullHarvestMode ? 'start_min_current_battery_full_harvest' :
|
||||||
|
forecastCommitMode ? 'start_min_current_forecast_commit' :
|
||||||
|
'start_min_current';
|
||||||
|
currentDecision = 'start';
|
||||||
|
dynamicOk = true;
|
||||||
|
mustStop = false;
|
||||||
|
nextBlockReason = 'none'; // Clean blocking logic: set reason to none on start
|
||||||
|
nextBlockedUntilStamp = now.stamp;
|
||||||
|
setProbeStartedAt = sourceMode === 'hybrid_probe';
|
||||||
|
reason.push(
|
||||||
|
sourceMode === 'grid_hc_allowed'
|
||||||
|
? "Démarrage en fenêtre HC réseau"
|
||||||
|
: sourceMode === 'hybrid_probe'
|
||||||
|
? "Démarrage en mode sonde"
|
||||||
|
: sourceMode === 'solar_potential'
|
||||||
|
? "Démarrage par potentiel solaire confirmé (SOC>=85%)" // NOUVEAU REASON TEXT
|
||||||
|
: batteryFullHarvestMode
|
||||||
|
? "Démarrage harvest solaire"
|
||||||
|
: forecastCommitMode
|
||||||
|
? "Démarrage forecast commit"
|
||||||
|
: "Démarrage prudent à courant mini"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
381
decoupesurplus/HA_SERVICE_CALLS_OUTPUT.js
Normal file
381
decoupesurplus/HA_SERVICE_CALLS_OUTPUT.js
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
// === FINAL ACTIONS & BLOQUAGE PROPRE ===
|
||||||
|
desiredCurrentA = desiredCharging
|
||||||
|
? clamp(desiredCurrentA, currentMinA, Math.max(currentMinA, physicalCapA || currentMinA))
|
||||||
|
: currentMinA;
|
||||||
|
const chargingAction =
|
||||||
|
desiredCharging === evcsChargingSwitchOn
|
||||||
|
? 'none'
|
||||||
|
: desiredCharging ? 'turn_on' : 'turn_off';
|
||||||
|
const permissionAction =
|
||||||
|
desiredPermission === chargePermissionNow
|
||||||
|
? 'none'
|
||||||
|
: desiredPermission ? 'turn_on' : 'turn_off';
|
||||||
|
const latchAction =
|
||||||
|
desiredLatch === controlLatch
|
||||||
|
? 'none'
|
||||||
|
: desiredLatch ? 'turn_on' : 'turn_off';
|
||||||
|
const autoStartAction =
|
||||||
|
controllerEnabled &&
|
||||||
|
evcsVehicleConnected &&
|
||||||
|
evcsAutoStartSwitchOn
|
||||||
|
? 'turn_off'
|
||||||
|
: 'none';
|
||||||
|
const currentAction =
|
||||||
|
desiredCharging &&
|
||||||
|
(
|
||||||
|
!Number.isFinite(currentReferenceA) ||
|
||||||
|
Math.abs(desiredCurrentA - currentReferenceA) >= 0.5
|
||||||
|
)
|
||||||
|
? 'set'
|
||||||
|
: 'none';
|
||||||
|
setLastStartAt = chargingAction === 'turn_on';
|
||||||
|
setLastStopAt = chargingAction === 'turn_off';
|
||||||
|
setLastCurrentChangeAt = currentAction === 'set';
|
||||||
|
|
||||||
|
// === BLOQUAGE PROPRE (Clean Blocking Logic) ===
|
||||||
|
const needsBlockReasonUpdate = (nextBlockReason !== 'none');
|
||||||
|
|
||||||
|
// === REASONS / SIGNATURE ===
|
||||||
|
const strategyLabel = isForceMode ? userStrategy : (userStrategy === 'auto_solar_plus' ? 'auto_plus' : 'auto');
|
||||||
|
reason.push(`Stratégie=${strategyLabel}`);
|
||||||
|
reason.push(`Profil=${profile}`);
|
||||||
|
reason.push(`Source=${sourceMode}`);
|
||||||
|
reason.push(`Mode=${batteryFullHarvestMode ? 'battery_full_harvest_mode' : forecastCommitMode ? 'forecast_commit_mode' : 'battery_priority_mode'}`);
|
||||||
|
reason.push(`SOC=${round1(soc)}%`);
|
||||||
|
reason.push(`Batt=${round1(batteryCurrentA)}A`);
|
||||||
|
reason.push(`PV=${Math.round(pvPowerW)}W`);
|
||||||
|
reason.push(`PV10=${Math.round(pvPowerAvg10mW)}W`);
|
||||||
|
reason.push(`Grid=${gridIsOn ? 'on' : 'off'} (${Math.round(gridPowerW)}W)`);
|
||||||
|
reason.push(`Cap=${physicalCapA}A`);
|
||||||
|
reason.push(`Ref=${round1(currentReferenceA)}A`);
|
||||||
|
reason.push(`Target=${round1(desiredCurrentA)}A`);
|
||||||
|
reason.push(`Debt=${round2(cloudDebtNextKwh)}kWh`);
|
||||||
|
reason.push(`Vehicle=${evcsVehicleConnected ? 'on' : 'off'}`);
|
||||||
|
reason.push(`EVCS mode=${evcsModeRaw}`);
|
||||||
|
reason.push(`VE.Bus AC=${Math.round(vebusAcOutPowerW)}W`);
|
||||||
|
reason.push(`VE.Bus DC=${Number.isFinite(vebusDcVoltageV) ? round1(vebusDcVoltageV) : 'n/a'}V`);
|
||||||
|
const shortReason = reason.join(' | ').slice(0, 255);
|
||||||
|
const sourceModeLabel =
|
||||||
|
sourceMode === 'blocked' ? 'Bloqué' :
|
||||||
|
sourceMode === 'solar_only' ? 'Solaire' :
|
||||||
|
sourceMode === 'solar_potential' ? 'Potentiel solaire' :
|
||||||
|
sourceMode === 'hybrid_probe' ? 'Probe hybride' :
|
||||||
|
sourceMode === 'grid_hc_allowed' ? 'Réseau HC autorisé' :
|
||||||
|
sourceMode === 'grid_forced' ? 'Réseau forcé' :
|
||||||
|
sourceMode;
|
||||||
|
const signature = [
|
||||||
|
state,
|
||||||
|
`profile=${profile}`,
|
||||||
|
`source=${sourceMode}`,
|
||||||
|
`chg=${desiredCharging ? '1' : '0'}`,
|
||||||
|
`perm=${desiredPermission ? '1' : '0'}`,
|
||||||
|
`l=${desiredLatch ? '1' : '0'}`,
|
||||||
|
`cur=${round1(desiredCurrentA)}`,
|
||||||
|
`block=${nextBlockReason}`,
|
||||||
|
`stop=${mustStop ? '1' : '0'}`
|
||||||
|
].join('|');
|
||||||
|
const signatureChanged = signature !== lastSignature;
|
||||||
|
|
||||||
|
// === NOTIFICATIONS ===
|
||||||
|
if (notificationsEnabled && signatureChanged) {
|
||||||
|
if (currentDecision === 'start') {
|
||||||
|
notifyWanted = true;
|
||||||
|
notifyTitle = '⚡ EVCS démarrage';
|
||||||
|
notifyMessage = shortReason;
|
||||||
|
} else if (currentDecision === 'step_up') {
|
||||||
|
notifyWanted = true;
|
||||||
|
notifyTitle = '⬆️ EVCS hausse courant';
|
||||||
|
notifyMessage = shortReason;
|
||||||
|
} else if (currentDecision === 'step_down' || currentDecision === 'step_down_fast') {
|
||||||
|
notifyWanted = true;
|
||||||
|
notifyTitle = '⬇️ EVCS baisse courant';
|
||||||
|
notifyMessage = shortReason;
|
||||||
|
} else if (mustStop || currentDecision === 'stop' || currentDecision === 'stop_hard' || currentDecision === 'stop_power_cap') {
|
||||||
|
notifyWanted = true;
|
||||||
|
notifyTitle = '⛔ EVCS arrêt';
|
||||||
|
notifyMessage = shortReason;
|
||||||
|
} else if (state === 'mode_auto_conflict') {
|
||||||
|
notifyWanted = true;
|
||||||
|
notifyTitle = '⚠️ EVCS mode Auto';
|
||||||
|
notifyMessage = shortReason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === OUTPUT ===
|
||||||
|
return [{
|
||||||
|
json: {
|
||||||
|
timestamp_paris: now.stamp,
|
||||||
|
inputs: input,
|
||||||
|
computed: {
|
||||||
|
operating_mode: operatingMode,
|
||||||
|
profile,
|
||||||
|
source_mode: sourceMode,
|
||||||
|
in_night_hc_window: inNightHcWindow,
|
||||||
|
in_morning_window: inMorningWindow,
|
||||||
|
in_midday_window: inMiddayWindow,
|
||||||
|
in_evening_window: inEveningWindow,
|
||||||
|
departure_priority_active: departurePriorityActive,
|
||||||
|
minutes_until_departure: minutesUntilDeparture,
|
||||||
|
battery_priority_mode: batteryPriorityMode,
|
||||||
|
battery_full_harvest_mode: batteryFullHarvestMode,
|
||||||
|
forecast_commit_mode: forecastCommitMode,
|
||||||
|
physical_cap_current_a: physicalCapA,
|
||||||
|
can_run_at_min_current: canRunAtMinCurrent,
|
||||||
|
forecast_buffer_kwh: round2(forecastBufferKwh),
|
||||||
|
projected_end_soc_after_charge: round1(projectedEndSocAfterCharge),
|
||||||
|
cloud_debt_next_kwh: round2(cloudDebtNextKwh),
|
||||||
|
cloud_debt_duration_minutes: round1(cloudDebtDurationMinutes),
|
||||||
|
cloud_debt_active: cloudDebtActive,
|
||||||
|
cloud_tolerance_ok: cloudToleranceOk,
|
||||||
|
start_blocked_by_reason: startBlockedByReason,
|
||||||
|
probe_window_active: probeWindowActive,
|
||||||
|
probe_success_now: probeSuccessNow,
|
||||||
|
probe_failed_now: probeFailedNow,
|
||||||
|
any_vebus_overload: anyVebusOverload,
|
||||||
|
any_vebus_low_battery: anyVebusLowBattery,
|
||||||
|
vebus_ac_out_soft_reached: vebusAcOutSoftReached,
|
||||||
|
vebus_ac_out_hard_reached: vebusAcOutHardReached,
|
||||||
|
vebus_dc_voltage_soft_low: vebusDcVoltageSoftLow,
|
||||||
|
vebus_dc_voltage_hard_low: vebusDcVoltageHardLow,
|
||||||
|
},
|
||||||
|
decision: {
|
||||||
|
state,
|
||||||
|
current_decision: currentDecision,
|
||||||
|
desired_charging: desiredCharging,
|
||||||
|
desired_permission: desiredPermission,
|
||||||
|
desired_latch: 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: setCloudDebtStartedAt,
|
||||||
|
set_probe_started_at: setProbeStartedAt,
|
||||||
|
// MODIFIÉ: Ne met à jour que si besoin réel
|
||||||
|
set_block_reason: needsBlockReasonUpdate || !controllerEnabled,
|
||||||
|
block_reason: nextBlockReason,
|
||||||
|
// FORÇAGE : Mettre à jour systématiquement pour éviter les timestamps passés
|
||||||
|
set_blocked_until: true,
|
||||||
|
// Si blocage actif = heure futur. Si pas de blocage = Maintenant (pour effacer l'ancien).
|
||||||
|
blocked_until: nextBlockReason === 'none' ? now.stamp : nextBlockedUntilStamp,
|
||||||
|
dynamic_ok: dynamicOk,
|
||||||
|
must_stop: mustStop,
|
||||||
|
short_reason: shortReason,
|
||||||
|
reason,
|
||||||
|
signature,
|
||||||
|
signature_changed: signatureChanged,
|
||||||
|
should_notify: notifyWanted,
|
||||||
|
notify_title: notifyTitle,
|
||||||
|
notify_message: notifyMessage,
|
||||||
|
},
|
||||||
|
ha_service_calls: {
|
||||||
|
helper_state: {
|
||||||
|
domain: 'input_text',
|
||||||
|
service: 'set_value',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_text.victron_evcs_surplus_state',
|
||||||
|
value: state
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_current_decision: {
|
||||||
|
domain: 'input_text',
|
||||||
|
service: 'set_value',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_text.victron_evcs_surplus_current_decision',
|
||||||
|
value: currentDecision
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_reason: {
|
||||||
|
domain: 'input_text',
|
||||||
|
service: 'set_value',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_text.victron_evcs_surplus_reason',
|
||||||
|
value: shortReason
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_operating_mode: {
|
||||||
|
domain: 'input_text',
|
||||||
|
service: 'set_value',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_text.victron_evcs_operating_mode',
|
||||||
|
value: operatingMode
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_target_current: {
|
||||||
|
domain: 'input_number',
|
||||||
|
service: 'set_value',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_number.victron_evcs_target_current_auto',
|
||||||
|
value: round1(desiredCurrentA)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_budget_remaining: {
|
||||||
|
domain: 'input_number',
|
||||||
|
service: 'set_value',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_number.victron_evcs_budget_remaining_kwh',
|
||||||
|
value: round2(forecastBufferKwh)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_projected_soc: {
|
||||||
|
domain: 'input_number',
|
||||||
|
service: 'set_value',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_number.victron_evcs_projected_end_soc_after_charge',
|
||||||
|
value: round1(projectedEndSocAfterCharge)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_cloud_debt: {
|
||||||
|
domain: 'input_number',
|
||||||
|
service: 'set_value',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_number.victron_evcs_cloud_debt_kwh',
|
||||||
|
value: round2(cloudDebtNextKwh)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_dynamic_ok: {
|
||||||
|
domain: 'input_boolean',
|
||||||
|
service: dynamicOk ? 'turn_on' : 'turn_off',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_boolean.victron_evcs_dynamic_ok'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_must_stop: {
|
||||||
|
domain: 'input_boolean',
|
||||||
|
service: mustStop ? 'turn_on' : 'turn_off',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_boolean.victron_evcs_must_stop'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_cloud_debt_started_at: {
|
||||||
|
domain: 'input_datetime',
|
||||||
|
service: 'set_datetime',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_datetime.victron_evcs_cloud_debt_started_at',
|
||||||
|
datetime: cloudDebtStartedMs ? formatParisStampFromMs(cloudDebtStartedMs) : now.stamp
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_last_current_change_at: {
|
||||||
|
domain: 'input_datetime',
|
||||||
|
service: 'set_datetime',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_datetime.victron_evcs_last_current_change_at',
|
||||||
|
datetime: now.stamp
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_last_start_at: {
|
||||||
|
domain: 'input_datetime',
|
||||||
|
service: 'set_datetime',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_datetime.victron_evcs_last_start_at',
|
||||||
|
datetime: now.stamp
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_last_stop_at: {
|
||||||
|
domain: 'input_datetime',
|
||||||
|
service: 'set_datetime',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_datetime.victron_evcs_last_stop_at',
|
||||||
|
datetime: now.stamp
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_signature: {
|
||||||
|
domain: 'input_text',
|
||||||
|
service: 'set_value',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_text.victron_evcs_control_last_signature',
|
||||||
|
value: signature
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_block_reason: {
|
||||||
|
domain: 'input_text',
|
||||||
|
service: 'set_value',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_text.victron_evcs_last_block_reason',
|
||||||
|
value: nextBlockReason
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_blocked_until: {
|
||||||
|
domain: 'input_datetime',
|
||||||
|
service: 'set_datetime',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_datetime.victron_evcs_blocked_until',
|
||||||
|
datetime: nextBlockedUntilStamp
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_probe_started_at: {
|
||||||
|
domain: 'input_datetime',
|
||||||
|
service: 'set_datetime',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_datetime.victron_evcs_probe_started_at',
|
||||||
|
datetime: now.stamp
|
||||||
|
}
|
||||||
|
},
|
||||||
|
evcs_auto_start_off: {
|
||||||
|
domain: 'switch',
|
||||||
|
service: 'turn_off',
|
||||||
|
data: {
|
||||||
|
entity_id: 'switch.victron_evcs_auto_start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
evcs_charging: {
|
||||||
|
domain: 'switch',
|
||||||
|
service: desiredCharging ? 'turn_on' : 'turn_off',
|
||||||
|
data: {
|
||||||
|
entity_id: 'switch.victron_evcs_charging'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_permission: {
|
||||||
|
domain: 'input_boolean',
|
||||||
|
service: desiredPermission ? 'turn_on' : 'turn_off',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_boolean.victron_evcs_charge_permission_now'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_latch: {
|
||||||
|
domain: 'input_boolean',
|
||||||
|
service: desiredLatch ? 'turn_on' : 'turn_off',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_boolean.victron_evcs_control_latch'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
helper_source_mode: {
|
||||||
|
execute: true,
|
||||||
|
domain: 'input_text',
|
||||||
|
service: 'set_value',
|
||||||
|
data: {
|
||||||
|
entity_id: 'input_text.victron_evcs_source_mode',
|
||||||
|
value: sourceModeLabel
|
||||||
|
}
|
||||||
|
},
|
||||||
|
evcs_set_current: {
|
||||||
|
domain: 'number',
|
||||||
|
service: 'set_value',
|
||||||
|
data: {
|
||||||
|
entity_id: 'number.victron_evcs_set_current_command',
|
||||||
|
value: round1(desiredCurrentA)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logbook: {
|
||||||
|
domain: 'logbook',
|
||||||
|
service: 'log',
|
||||||
|
data: {
|
||||||
|
name: 'Victron EVCS Surplus',
|
||||||
|
message: shortReason
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mobile_notification: {
|
||||||
|
domain: CFG.mobileNotifyDomain,
|
||||||
|
service: CFG.mobileNotifyService,
|
||||||
|
data: {
|
||||||
|
title: notifyTitle,
|
||||||
|
message: notifyMessage || shortReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
246
decoupesurplus/INPUT_PARSE.js
Normal file
246
decoupesurplus/INPUT_PARSE.js
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
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 minutesSince(ts, nowMs) {
|
||||||
|
if (ts === null || nowMs === null) return null;
|
||||||
|
return Math.max(0, (nowMs - ts) / 60000);
|
||||||
|
}
|
||||||
|
function secondsSince(ts, nowMs) {
|
||||||
|
if (ts === null || nowMs === null) return null;
|
||||||
|
return Math.max(0, (nowMs - ts) / 1000);
|
||||||
|
}
|
||||||
|
function isWithinWindow(nowMin, startMin, stopMin) {
|
||||||
|
if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;
|
||||||
|
if (startMin === stopMin) return false;
|
||||||
|
if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;
|
||||||
|
return nowMin >= startMin || nowMin < stopMin;
|
||||||
|
}
|
||||||
|
function 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 formatParisStampFromMs(ms) {
|
||||||
|
return getParisParts(ms).stamp;
|
||||||
|
}
|
||||||
|
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 CFG = {
|
||||||
|
mobileNotifyDomain: 'notify',
|
||||||
|
mobileNotifyService: 'mobile_app_iphone_16_de_tt',
|
||||||
|
batteryUsableCapacityKwh: 17.5,
|
||||||
|
nominalAcVoltageV: 230,
|
||||||
|
evcsDedicatedMaxW: 4500,
|
||||||
|
systemReserveW: 300,
|
||||||
|
vebusDcVoltageSoftFloorV: 49.0,
|
||||||
|
vebusDcVoltageHardFloorV: 48.6,
|
||||||
|
departurePriorityWindowMinutes: 240,
|
||||||
|
sampleSeconds: 30,
|
||||||
|
cloudDebtRecoveryThresholdKwh: 0.05,
|
||||||
|
cloudDebtMeaningfulThresholdKwh: 0.01,
|
||||||
|
};
|
||||||
|
const raw = $input.first().json;
|
||||||
|
const input = extractSnapshot(raw);
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const now = getParisParts(nowMs);
|
||||||
|
|
||||||
|
// === INPUTS ===
|
||||||
|
const soc = clamp(num(input.soc_percent, 0), 0, 100);
|
||||||
|
const batteryCurrentA = num(input.battery_current_a, 0);
|
||||||
|
const pvPowerW = Math.max(0, num(input.pv_power_w, 0));
|
||||||
|
const pvPowerAvg10mW = Math.max(0, num(input.pv_power_10m_avg_w, pvPowerW));
|
||||||
|
const pvTrendW = num(input.pv_trend_w, 0);
|
||||||
|
const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));
|
||||||
|
const gridPowerW = num(input.grid_power_w, 0);
|
||||||
|
const gridIsOn = low(input.grid_state || 'off') === 'on';
|
||||||
|
const todayRemainingKwh = Math.max(0, num(input.today_remaining_kwh, 0));
|
||||||
|
const evcsDeviceConnected = low(input.evcs_device_connected || 'off') === 'on';
|
||||||
|
const evcsVehicleConnected = low(input.evcs_vehicle_connected || 'off') === 'on';
|
||||||
|
const evcsStatusRaw = (input.evcs_status_raw ?? '').toString().trim();
|
||||||
|
const evcsModeRaw = [input.evcs_mode_sensor, input.evcs_mode_fallback, input.evcs_mode]
|
||||||
|
.map(v => (v ?? '').toString().trim())
|
||||||
|
.find(v => v && low(v) !== 'unknown' && low(v) !== 'unavailable') || 'unknown';
|
||||||
|
const evcsModeIsAuto = low(evcsModeRaw).includes('auto');
|
||||||
|
const evcsSetCurrentA = num(input.evcs_set_current_a, NaN);
|
||||||
|
const evcsCommandCurrentA = num(input.evcs_command_current_a, NaN);
|
||||||
|
const evcsCurrentA = Math.max(0, num(input.evcs_current_a, 0));
|
||||||
|
const evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));
|
||||||
|
const evcsChargingSwitchOn = low(input.evcs_charging_switch || 'off') === 'on';
|
||||||
|
const evcsAutoStartSwitchOn = low(input.evcs_auto_start_switch || 'off') === 'on';
|
||||||
|
const controllerEnabled = low(input.controller_enabled || 'off') === 'on';
|
||||||
|
const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';
|
||||||
|
const hardStopEnabledLegacy = low(input.hard_stop_enabled || 'off') === 'on';
|
||||||
|
const chargePermissionNow = low(input.charge_permission_now || 'off') === 'on';
|
||||||
|
const controlLatch = low(input.control_latch || 'off') === 'on';
|
||||||
|
const departurePriorityEnabled = low(input.departure_priority_enabled || 'off') === 'on';
|
||||||
|
const forecastModeEnabled = low(input.forecast_mode_enabled || 'off') === 'on';
|
||||||
|
const targetCurrentAutoA = clamp(num(input.target_current_auto_a, 6), 0, 32);
|
||||||
|
const pvStartMinW = Math.max(0, num(input.pv_start_min_w, 1800));
|
||||||
|
const pvHoldMinW = Math.max(0, num(input.pv_hold_min_w, 1200));
|
||||||
|
const houseAcHeadroomW = Math.max(0, num(input.house_ac_headroom_w, 1200));
|
||||||
|
const batteryDischargeSoftA = num(input.battery_discharge_soft_a, -5);
|
||||||
|
const batteryDischargeHardA = num(input.battery_discharge_hard_a, -15);
|
||||||
|
const batteryReserveUpA = Math.max(0, num(input.battery_reserve_up_a, 8));
|
||||||
|
const batteryNeutralBandA = Math.max(0, num(input.battery_neutral_band_a, 3));
|
||||||
|
const socStartMin = clamp(num(input.soc_start_min, 60), 0, 100);
|
||||||
|
const socStopMin = clamp(num(input.soc_stop_min, 35), 0, 100);
|
||||||
|
const currentMinA = clamp(num(input.current_min_a, 6), 6, 32);
|
||||||
|
const rawCurrentMaxAutoA = input.current_max_auto_a ?? null;
|
||||||
|
const parsedCurrentMaxAutoA = num(rawCurrentMaxAutoA, 16);
|
||||||
|
const currentMaxAutoA = clamp(parsedCurrentMaxAutoA, currentMinA, 32);
|
||||||
|
const currentStepUpA = clamp(num(input.current_step_up_a, 1), 1, 5);
|
||||||
|
const currentStepDownA = clamp(num(input.current_step_down_a, 2), 1, 8);
|
||||||
|
const currentChangeCooldownSeconds = Math.max(5, num(input.current_change_cooldown_seconds, 60));
|
||||||
|
const minRunMinutes = Math.max(0, num(input.min_run_minutes, 3));
|
||||||
|
const minStopMinutes = Math.max(0, num(input.min_stop_minutes, 10));
|
||||||
|
const requiredEnergyKwh = Math.max(0, num(input.required_energy_kwh, 0));
|
||||||
|
const socComfortTarget = clamp(num(input.soc_comfort_target, 85), 0, 100);
|
||||||
|
const endOfDaySocGuard = clamp(num(input.end_of_day_soc_guard, 85), 0, 100);
|
||||||
|
const baseLoadRemainingKwh = Math.max(0, num(input.base_load_remaining_kwh, 0));
|
||||||
|
const flexibleSafetyMarginKwh = Math.max(0, num(input.flexible_safety_margin_kwh, 0));
|
||||||
|
const endOfDaySocTarget = clamp(num(input.end_of_day_soc_target, 85), 0, 100);
|
||||||
|
const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 8500));
|
||||||
|
const batteryFullSoc = clamp(num(input.battery_full_soc, 90), 0, 100);
|
||||||
|
const batteryFullForecastMinKwh = Math.max(0, num(input.battery_full_forecast_min_kwh, 8));
|
||||||
|
const forecastCommitSocFloor = clamp(num(input.forecast_commit_soc_floor, 35), 0, 100);
|
||||||
|
const forecastCommitMinKwh = Math.max(0, num(input.forecast_commit_min_kwh, 25));
|
||||||
|
const cloudDebtPrevKwh = Math.max(0, num(input.cloud_debt_kwh, 0));
|
||||||
|
const cloudDebtMaxKwh = Math.max(0.05, num(input.cloud_debt_max_kwh, 1.2));
|
||||||
|
const cloudToleranceMinutes = Math.max(1, num(input.cloud_tolerance_minutes, 20));
|
||||||
|
const departureTimeMin = parseTimeToMinutes(input.departure_time, null);
|
||||||
|
const dayStartTimeMin = parseTimeToMinutes(input.day_start_time, 7 * 60 + 30);
|
||||||
|
const dayStopTimeMin = parseTimeToMinutes(input.day_stop_time, 20 * 60 + 30);
|
||||||
216
decoupesurplus/WINDOWS_SOURCE.js
Normal file
216
decoupesurplus/WINDOWS_SOURCE.js
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
// === STRATÉGIE UTILISATEUR (V2.0 - NEW) ===
|
||||||
|
const userStrategyRaw = input.victron_evcs_strategy || 'auto_solar';
|
||||||
|
const userStrategy = low(userStrategyRaw); // auto_solar | auto_solar_plus | force_grid_now | force_charge_now | stop
|
||||||
|
// Mode Force (Ignore HC strict & PV minimaux)
|
||||||
|
const isForceMode = (userStrategy === 'force_grid_now' || userStrategy === 'force_charge_now');
|
||||||
|
const isStopMode = (userStrategy === 'stop');
|
||||||
|
// Override for Force modes
|
||||||
|
let effectivePvStartMinW = pvStartMinW;
|
||||||
|
let effectiveAllowGridMidday = low(input.allow_grid_hc_midday || 'off') === 'on';
|
||||||
|
if (isForceMode) {
|
||||||
|
effectivePvStartMinW = 0;
|
||||||
|
effectiveAllowGridMidday = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === OPTIONAL V1.4 INPUTS ===
|
||||||
|
const hcNightStartMin = parseTimeToMinutes(input.hc_night_start_time, 23 * 60);
|
||||||
|
const hcNightStopMin = parseTimeToMinutes(input.hc_night_stop_time, 6 * 60);
|
||||||
|
const morningStartMin = parseTimeToMinutes(input.morning_start_time, dayStartTimeMin);
|
||||||
|
const morningStopMin = parseTimeToMinutes(input.morning_stop_time, 11 * 60);
|
||||||
|
const hcMiddayStartMin = parseTimeToMinutes(input.hc_midday_start_time, 12 * 60 + 24);
|
||||||
|
const hcMiddayStopMin = parseTimeToMinutes(input.hc_midday_stop_time, 14 * 60 + 24);
|
||||||
|
const eveningStartMin = parseTimeToMinutes(input.evening_start_time, 17 * 60);
|
||||||
|
const eveningStopMin = parseTimeToMinutes(input.evening_stop_time, dayStopTimeMin);
|
||||||
|
const allowGridHcNight = low(input.allow_grid_hc_night || 'off') === 'on';
|
||||||
|
const allowGridHcMidday = effectiveAllowGridMidday; // Utilise le override si force mode
|
||||||
|
const probeEnabled = low(input.probe_enabled || 'off') === 'on';
|
||||||
|
const probeMinutes = Math.max(1, num(input.probe_minutes, 3));
|
||||||
|
const probeRetryMinutes = Math.max(5, num(input.probe_retry_minutes, 45));
|
||||||
|
const probeCurrentA = clamp(num(input.probe_current_a, currentMinA), currentMinA, currentMaxAutoA);
|
||||||
|
const gridBatterySoftA = num(input.grid_battery_soft_a, -3);
|
||||||
|
const gridBatteryHardA = num(input.grid_battery_hard_a, -6);
|
||||||
|
const morningProbePvAvgMinW = Math.max(0, num(input.morning_probe_pv_avg_min_w, 1500));
|
||||||
|
const morningProbePvTrendMinW = num(input.morning_probe_pv_trend_min_w, 0);
|
||||||
|
const middayProbePvAvgMinW = Math.max(0, num(input.midday_probe_pv_avg_min_w, 1200));
|
||||||
|
const morningSocMin = clamp(num(input.morning_soc_min, 35), 0, 100);
|
||||||
|
const middaySocMin = clamp(num(input.midday_soc_min, 75), 0, 100);
|
||||||
|
const eveningSocStartMin = clamp(num(input.evening_soc_start_min, 85), 0, 100);
|
||||||
|
const eveningSocStopMin = clamp(num(input.evening_soc_stop_min, 80), 0, 100);
|
||||||
|
const morningBatteryMaxDischargeA = num(input.morning_battery_max_discharge_a, -20);
|
||||||
|
const middayBatteryMaxDischargeA = num(input.midday_battery_max_discharge_a, -40);
|
||||||
|
const eveningBatteryMaxDischargeA = num(input.evening_battery_max_discharge_a, -30);
|
||||||
|
const lastBlockReason = (input.last_block_reason ?? '').toString().trim() || 'none';
|
||||||
|
const blockedUntilAt = (input.blocked_until ?? '').toString().trim();
|
||||||
|
const probeStartedAt = (input.probe_started_at ?? '').toString().trim();
|
||||||
|
const lastStartAt = (input.last_start_at ?? '').toString().trim();
|
||||||
|
const lastStopAt = (input.last_stop_at ?? '').toString().trim();
|
||||||
|
const lastCurrentChangeAt = (input.last_current_change_at ?? '').toString().trim();
|
||||||
|
const lastSignature = (input.last_signature ?? '').toString().trim();
|
||||||
|
const cloudDebtStartedAt = (input.cloudDebtStartedAt ?? '').toString().trim();
|
||||||
|
const lastStartMs = parseParisStampToMs(lastStartAt);
|
||||||
|
const lastStopMs = parseParisStampToMs(lastStopAt);
|
||||||
|
const lastCurrentChangeMs = parseParisStampToMs(lastCurrentChangeAt);
|
||||||
|
const blockedUntilMs = parseParisStampToMs(blockedUntilAt);
|
||||||
|
const probeStartedMs = parseParisStampToMs(probeStartedAt);
|
||||||
|
const cloudDebtStartedMsPrev = parseParisStampToMs(cloudDebtStartedAt);
|
||||||
|
const minutesSinceStart = minutesSince(lastStartMs, nowMs);
|
||||||
|
const minutesSinceStop = minutesSince(lastStopMs, nowMs);
|
||||||
|
const secondsSinceCurrentChange = secondsSince(lastCurrentChangeMs, nowMs);
|
||||||
|
const minutesSinceProbeStart = minutesSince(probeStartedMs, nowMs);
|
||||||
|
const minRunElapsed = minutesSinceStart === null ? true : minutesSinceStart >= minRunMinutes;
|
||||||
|
const minStopElapsed = minutesSinceStop === null ? true : minutesSinceStop >= minStopMinutes;
|
||||||
|
const currentCooldownElapsed = secondsSinceCurrentChange === null ? true : secondsSinceCurrentChange >= currentChangeCooldownSeconds;
|
||||||
|
const minutesUntilDeparture =
|
||||||
|
departureTimeMin === null
|
||||||
|
? null
|
||||||
|
: (() => {
|
||||||
|
const rawDiff = departureTimeMin - now.minNow;
|
||||||
|
return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + departureTimeMin);
|
||||||
|
})();
|
||||||
|
const departurePriorityActive =
|
||||||
|
departurePriorityEnabled &&
|
||||||
|
Number.isFinite(minutesUntilDeparture) &&
|
||||||
|
minutesUntilDeparture >= 0 &&
|
||||||
|
minutesUntilDeparture <= CFG.departurePriorityWindowMinutes &&
|
||||||
|
evcsVehicleConnected;
|
||||||
|
|
||||||
|
// === VE.BUS INPUTS ===
|
||||||
|
const vebusOverload = low(input.vebus_overload || 'off') === 'on';
|
||||||
|
const vebusL1Overload = low(input.vebus_l1_overload || 'off') === 'on';
|
||||||
|
const vebusLowBattery = low(input.vebus_low_battery || 'off') === 'on';
|
||||||
|
const vebusL1LowBattery = low(input.vebus_l1_low_battery || 'off') === 'on';
|
||||||
|
const vebusAcOutPowerW = Math.max(0, num(input.vebus_ac_out_l1_power_w, 0));
|
||||||
|
const vebusAcOutCurrentA = Math.max(0, num(input.vebus_ac_out_l1_current_a, 0));
|
||||||
|
const vebusAcOutVoltageV = num(input.vebus_ac_out_l1_voltage_v, CFG.nominalAcVoltageV);
|
||||||
|
const vebusDcVoltageV = num(input.vebus_dc_voltage_v, NaN);
|
||||||
|
const vebusDcCurrentA = num(input.vebus_dc_current_a, NaN);
|
||||||
|
const vebusDcPowerW = num(input.vebus_dc_power_w, NaN);
|
||||||
|
const vebusState = num(input.vebus_state, NaN);
|
||||||
|
const vebusMainState = num(input.vebus_main_state, NaN);
|
||||||
|
|
||||||
|
// === DERIVED ===
|
||||||
|
const acVoltageRef = Number.isFinite(vebusAcOutVoltageV) && vebusAcOutVoltageV > 180
|
||||||
|
? vebusAcOutVoltageV
|
||||||
|
: CFG.nominalAcVoltageV;
|
||||||
|
const batteryVoltageRef = Number.isFinite(vebusDcVoltageV) && vebusDcVoltageV > 0
|
||||||
|
? vebusDcVoltageV
|
||||||
|
: 51.2;
|
||||||
|
const houseNonEvcsW = Math.max(0, houseAcPowerW - evcsAcPowerW);
|
||||||
|
const vebusNonEvcsW = Math.max(0, vebusAcOutPowerW - evcsAcPowerW);
|
||||||
|
const totalVebusSoftCeilingW = Math.max(6500, acLoadsMaxSafeW || 8500);
|
||||||
|
const totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;
|
||||||
|
const safeEvcsByDedicatedCapW = Math.max(0, CFG.evcsDedicatedMaxW - CFG.systemReserveW);
|
||||||
|
const safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - CFG.systemReserveW);
|
||||||
|
const safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));
|
||||||
|
const systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));
|
||||||
|
const physicalCapA = clamp(systemCapCurrentA, 0, currentMaxAutoA);
|
||||||
|
const canRunAtMinCurrent = physicalCapA >= currentMinA;
|
||||||
|
const endDayGuardSoc = Math.max(endOfDaySocGuard, endOfDaySocTarget);
|
||||||
|
const energyNeededForEndDayGuardKwh = Math.max(0, ((endDayGuardSoc - soc) / 100) * CFG.batteryUsableCapacityKwh);
|
||||||
|
const forecastBufferKwh =
|
||||||
|
todayRemainingKwh -
|
||||||
|
baseLoadRemainingKwh -
|
||||||
|
flexibleSafetyMarginKwh -
|
||||||
|
requiredEnergyKwh -
|
||||||
|
energyNeededForEndDayGuardKwh;
|
||||||
|
const projectedEndSocAfterCharge = clamp(
|
||||||
|
soc + (
|
||||||
|
(
|
||||||
|
todayRemainingKwh -
|
||||||
|
baseLoadRemainingKwh -
|
||||||
|
flexibleSafetyMarginKwh -
|
||||||
|
requiredEnergyKwh
|
||||||
|
) / CFG.batteryUsableCapacityKwh
|
||||||
|
) * 100,
|
||||||
|
0,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
const currentReferenceA = clamp(
|
||||||
|
Number.isFinite(evcsCommandCurrentA)
|
||||||
|
? evcsCommandCurrentA
|
||||||
|
: Number.isFinite(evcsSetCurrentA)
|
||||||
|
? evcsSetCurrentA
|
||||||
|
: targetCurrentAutoA,
|
||||||
|
0,
|
||||||
|
32
|
||||||
|
);
|
||||||
|
const anyVebusOverload = vebusOverload || vebusL1Overload;
|
||||||
|
const anyVebusLowBattery = vebusLowBattery || vebusL1LowBattery;
|
||||||
|
const vebusAcOutSoftReached = vebusAcOutPowerW >= totalVebusSoftCeilingW;
|
||||||
|
const vebusAcOutHardReached = vebusAcOutPowerW >= totalVebusHardCeilingW;
|
||||||
|
const vebusDcVoltageSoftLow =
|
||||||
|
Number.isFinite(vebusDcVoltageV) &&
|
||||||
|
vebusDcVoltageV > 0 &&
|
||||||
|
vebusDcVoltageV <= CFG.vebusDcVoltageSoftFloorV;
|
||||||
|
const vebusDcVoltageHardLow =
|
||||||
|
Number.isFinite(vebusDcVoltageV) &&
|
||||||
|
vebusDcVoltageV > 0 &&
|
||||||
|
vebusDcVoltageV <= CFG.vebusDcVoltageHardFloorV;
|
||||||
|
const houseLoadHighVsHeadroom = houseNonEvcsW > houseAcHeadroomW;
|
||||||
|
|
||||||
|
// === WINDOWS / PROFILE / SOURCE MODE (With Solar Potential Logic) ===
|
||||||
|
const inNightHcWindow = isWithinWindow(now.minNow, hcNightStartMin, hcNightStopMin);
|
||||||
|
const inMorningWindow = isWithinWindow(now.minNow, morningStartMin, morningStopMin);
|
||||||
|
const inMiddayWindow = isWithinWindow(now.minNow, hcMiddayStartMin, hcMiddayStopMin);
|
||||||
|
const inEveningWindow = isWithinWindow(now.minNow, eveningStartMin, eveningStopMin);
|
||||||
|
const profile =
|
||||||
|
inNightHcWindow ? 'night_hc' :
|
||||||
|
inMorningWindow ? 'morning_solar' :
|
||||||
|
inMiddayWindow ? 'midday_hc_or_solar' :
|
||||||
|
inEveningWindow ? 'evening_high_soc' :
|
||||||
|
'outside_window';
|
||||||
|
const morningProbeEligible =
|
||||||
|
profile === 'morning_solar' &&
|
||||||
|
probeEnabled &&
|
||||||
|
pvPowerAvg10mW >= morningProbePvAvgMinW &&
|
||||||
|
pvTrendW >= morningProbePvTrendMinW &&
|
||||||
|
soc >= morningSocMin &&
|
||||||
|
batteryCurrentA >= morningBatteryMaxDischargeA;
|
||||||
|
const middayProbeEligible =
|
||||||
|
profile === 'midday_hc_or_solar' &&
|
||||||
|
probeEnabled &&
|
||||||
|
pvPowerAvg10mW >= middayProbePvAvgMinW &&
|
||||||
|
soc >= middaySocMin &&
|
||||||
|
batteryCurrentA >= middayBatteryMaxDischargeA;
|
||||||
|
// Solar Potential Condition (High Battery + Forecast)
|
||||||
|
const cloudDebtOkForPotential = cloudDebtPrevKwh <= cloudDebtMaxKwh;
|
||||||
|
const batteryOkForPotential = batteryCurrentA >= -Math.max(1, batteryNeutralBandA);
|
||||||
|
const isHighBatteryForPotential =
|
||||||
|
soc >= 85 &&
|
||||||
|
forecastBufferKwh > 0 &&
|
||||||
|
cloudDebtOkForPotential &&
|
||||||
|
batteryOkForPotential;
|
||||||
|
const potentialAllowed = userStrategy !== 'auto_solar'; // auto_solar_plus autorise aussi
|
||||||
|
const isForcePotential = userStrategy === 'force_charge_now' || userStrategy === 'force_grid_now';
|
||||||
|
const strongSolarSignal =
|
||||||
|
pvPowerW >= effectivePvStartMinW ||
|
||||||
|
pvPowerAvg10mW >= pvHoldMinW ||
|
||||||
|
(departurePriorityActive && pvPowerAvg10mW >= pvHoldMinW);
|
||||||
|
let sourceMode = 'blocked';
|
||||||
|
if (isForcePotential) {
|
||||||
|
sourceMode = 'grid_forced'; // Priorité à l'utilisateur
|
||||||
|
} else if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {
|
||||||
|
if (strongSolarSignal) {
|
||||||
|
sourceMode = 'solar_only';
|
||||||
|
} else if (isHighBatteryForPotential && potentialAllowed) {
|
||||||
|
sourceMode = 'solar_potential'; // Le nouveau comportement clé !
|
||||||
|
} else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {
|
||||||
|
sourceMode = 'hybrid_probe';
|
||||||
|
} else if (allowGridHcMidday && gridIsOn) {
|
||||||
|
sourceMode = 'grid_hc_allowed';
|
||||||
|
} else {
|
||||||
|
sourceMode = 'blocked'; // Fallback strict si PV bridé et pas de potentiel clair
|
||||||
|
}
|
||||||
|
} else if (profile === 'night_hc') {
|
||||||
|
sourceMode = allowGridHcNight && gridIsOn ? 'grid_hc_allowed' : 'blocked';
|
||||||
|
} else if (profile === 'evening_high_soc') {
|
||||||
|
sourceMode = strongSolarSignal ? 'solar_only' : 'blocked';
|
||||||
|
} else if (profile === 'outside_window' && userStrategy !== 'auto_solar') {
|
||||||
|
// Nouvelle condition : autorise charge en dehors fenêtre si stratégie auto (pas strict auto_solar)
|
||||||
|
if (strongSolarSignal) {
|
||||||
|
sourceMode = 'solar_only';
|
||||||
|
} else if (isHighBatteryForPotential && potentialAllowed) {
|
||||||
|
sourceMode = 'solar_potential';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const operatingMode = profile;
|
||||||
Reference in New Issue
Block a user