avant analyse et doc

This commit is contained in:
Tallagrand Thierry
2026-06-11 22:38:15 +02:00
parent ae725602d0
commit 349a77af26
7 changed files with 16394 additions and 0 deletions

13143
Notes/kimievcs.md Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,955 @@
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';
const isLocalSolarSource =
context.sourceMode === 'solar_only' ||
context.sourceMode === 'solar_potential';
// =====================================================
// 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
);
// =====================================================
// OUTSIDE-WINDOW LOCAL BYPASS
// IMPORTANT:
// en auto_solar_plus ou force_charge_now, un contexte local solaire
// ne doit plus rester bloqué juste parce qu'on est hors fenêtre.
// =====================================================
const outsideWindowLocalBypass =
context.profile === 'outside_window' &&
!strategy.forceGridRequested &&
(
strategy.forceChargeRequested ||
strategy.userStrategy === 'auto_solar_plus'
) &&
isLocalSolarSource;
// =====================================================
// 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' && !outsideWindowLocalBypass)
: 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;
// =====================================================
// EVCS NIGHT ENGINE
// =====================================================
const nightSessionActive =
context.profile === 'night_hc' &&
parsed.evcsVehicleConnected;
const nightSessionStartSocPct =
nightSessionActive
? (
Number.isFinite(history.nightSessionStartSocPct)
? history.nightSessionStartSocPct
: parsed.soc
)
: null;
const nightSessionStartAt =
nightSessionActive
? (history.nightSessionStartAt || '')
: '';
const nightSessionStartMs =
nightSessionActive
? (history.nightSessionStartMs ?? null)
: null;
const nightSessionNeedsCapture =
nightSessionActive &&
(
!Number.isFinite(history.nightSessionStartSocPct) ||
!nightSessionStartAt ||
nightSessionStartMs === null
);
const nightSessionNeedsClear =
!nightSessionActive &&
(
Number.isFinite(history.nightSessionStartSocPct) ||
!!history.nightSessionStartAt
);
const batteryHardFloorPct =
params.evcsNightBatteryHardFloorPct;
const batteryMorningTargetPct =
parsed.todayRemainingKwh >= params.evcsNightStrongForecastKwh
? params.evcsNightTargetStrongForecastPct
: parsed.todayRemainingKwh >= params.evcsNightMediumForecastKwh
? params.evcsNightTargetMediumForecastPct
: params.evcsNightTargetWeakForecastPct;
const maxBatteryDischargePoints =
nightSessionActive && Number.isFinite(nightSessionStartSocPct)
? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)
: 0;
const gridTakeoverTriggerPct =
Math.max(batteryHardFloorPct, batteryMorningTargetPct);
const batteryKwhMissingToTarget =
nightSessionActive
? Math.max(
0,
((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh
)
: 0;
const batteryRecoveryMinutesNeeded =
nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0
? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60
: 0;
const batteryRecoveryUrgent =
nightSessionActive &&
Number.isFinite(lifecycle.minutesUntilDeparture) &&
lifecycle.minutesUntilDeparture >= 0 &&
(
batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin
>= lifecycle.minutesUntilDeparture
);
const batteryBelowHardFloor =
parsed.soc <= batteryHardFloorPct;
const batteryBelowMorningTarget =
parsed.soc < batteryMorningTargetPct;
const batteryAtOrBelowGridTakeoverTrigger =
parsed.soc <= gridTakeoverTriggerPct;
const batteryAssistAllowed =
nightSessionActive &&
!batteryBelowHardFloor &&
!batteryRecoveryUrgent &&
parsed.soc > gridTakeoverTriggerPct;
const nightGridTakeoverNeeded =
nightSessionActive &&
(
batteryBelowHardFloor ||
batteryAtOrBelowGridTakeoverTrigger ||
batteryRecoveryUrgent
);
const nightPreferredSourceMode =
!nightSessionActive
? 'blocked'
: batteryAssistAllowed
? 'battery_assist_night'
: (
batteryBelowHardFloor ||
batteryBelowMorningTarget ||
batteryRecoveryUrgent
)
? 'grid_forced'
: 'grid_hc_allowed';
const batteryRecoveryAllowedNow =
nightSessionActive &&
nightGridTakeoverNeeded &&
(
batteryBelowMorningTarget ||
batteryRecoveryUrgent
);
const batteryTargetReached =
nightSessionActive &&
parsed.soc >= batteryMorningTargetPct;
// =====================================================
// 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;
// =====================================================
// BATTERY-ASSISTED SOLAR START/HOLD
// IMPORTANT:
// Installation sans injection:
// le PV visible peut rester bas tant que l'EVCS ne démarre pas.
// On autorise donc un démarrage/maintien local si la batterie est haute
// et que la dette nuage reste faible.
// =====================================================
const batteryAssistSolarPolicyEligible =
!strategy.forceGridRequested &&
(
strategy.forceChargeRequested ||
strategy.userStrategy === 'auto_solar_plus'
) &&
isLocalSolarSource;
const batteryAssistSolarStartSocMin = 90;
const batteryAssistSolarHoldSocMin = 85;
const batteryAssistSolarDebtStartMaxKwh = Math.max(
cfg.cloudDebtRecoveryThresholdKwh,
Math.min(params.cloudDebtMaxKwh, 0.05)
);
const batteryAssistSolarStartOk =
batteryAssistSolarPolicyEligible &&
commonSafetyStartOk &&
!isGridSource &&
parsed.soc >= Math.max(profileSocStartFloor, batteryAssistSolarStartSocMin) &&
parsed.soc > batteryAssistSolarHoldSocMin &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
cloudDebtNextKwh <= batteryAssistSolarDebtStartMaxKwh &&
!cloudDebtActive;
const batteryAssistSolarHoldOk =
batteryAssistSolarPolicyEligible &&
commonSafetyHoldOk &&
!isGridSource &&
parsed.soc >= Math.max(profileSocHoldFloor, batteryAssistSolarHoldSocMin) &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
cloudToleranceOk;
// =====================================================
// START / HOLD CONDITIONS
// =====================================================
const rawSolarStartOk =
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
commonSafetyStartOk &&
parsed.soc >= profileSocStartFloor &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
(
context.sourceMode === 'solar_potential' ||
parsed.pvPowerW >= context.effectivePvStartMinW
);
const solarStartOk =
rawSolarStartOk || batteryAssistSolarStartOk;
const rawSolarHoldOk =
(context.sourceMode === 'solar_only' || context.sourceMode === 'solar_potential') &&
commonSafetyHoldOk &&
parsed.soc >= profileSocHoldFloor &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA &&
(
parsed.pvPowerW >= params.pvHoldMinW ||
cloudToleranceOk
);
const solarHoldOk =
rawSolarHoldOk || batteryAssistSolarHoldOk;
// FIX V2.5: Garde SOC - pas de grid pour EVCS si SOC > 35% sauf force_grid_now
const gridAllowedBySoc = parsed.soc <= 35 || strategy.forceGridRequested;
const gridStartOk =
gridAllowedBySoc &&
isGridSource &&
commonSafetyStartOk &&
parsed.gridAvailable &&
parsed.batteryCurrentA >= gridBatterySoftDynamicA;
const gridHoldOk =
gridAllowedBySoc &&
isGridSource &&
commonSafetyHoldOk &&
parsed.gridAvailable &&
parsed.batteryCurrentA >= gridBatteryHardDynamicA;
const batteryAssistNightStartOk =
context.sourceMode === 'battery_assist_night' &&
commonSafetyStartOk &&
nightSessionActive &&
batteryAssistAllowed &&
parsed.soc > batteryHardFloorPct &&
parsed.soc > gridTakeoverTriggerPct &&
parsed.batteryCurrentA >= profileBatteryDischargeFloorA;
const batteryAssistNightHoldOk =
context.sourceMode === 'battery_assist_night' &&
commonSafetyHoldOk &&
nightSessionActive &&
batteryAssistAllowed &&
parsed.soc > batteryHardFloorPct &&
parsed.soc > gridTakeoverTriggerPct &&
parsed.batteryCurrentA >= batteryDischargeHardDynamicA;
// =====================================================
// 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;
const batteryAssistNightShouldStepUp =
context.sourceMode === 'battery_assist_night' &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA < derived.physicalCapA &&
derived.physicalCapA >= params.currentMinA &&
!derived.vebusAcOutSoftReached &&
!derived.vebusDcVoltageSoftLow &&
!derived.houseLoadHighVsHeadroom &&
parsed.batteryCurrentA >= batteryChargeReserveDynamicA &&
batteryAssistAllowed;
// =====================================================
// 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 batteryAssistNightShouldStepDown =
context.sourceMode === 'battery_assist_night' &&
lifecycle.currentCooldownElapsed &&
derived.currentReferenceA > params.currentMinA &&
(
parsed.batteryCurrentA <= batteryDischargeSoftDynamicA ||
parsed.soc <= gridTakeoverTriggerPct ||
batteryRecoveryUrgent
);
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,
batteryAssistSolarStartSocMin,
batteryAssistSolarHoldSocMin,
batteryAssistSolarDebtStartMaxKwh,
houseSpikeDetected,
solarPotentialContext,
outsideWindowLocalBypass,
},
locks: {
nextAllowedWindowStartMs,
blockActive,
blockStillRelevant,
startBlockedByReason,
},
cloud: {
sessionObservedActive,
batteryNetPowerW,
cloudDebtNextKwh,
cloudDebtMeaningful,
cloudEventDischargingNow,
continuingCloudEvent,
enteringCloudDebt,
cloudDebtActive,
cloudDebtStartedMs,
cloudDebtDurationMinutes,
effectiveCloudToleranceMinutes,
cloudToleranceOk,
},
modes: {
batteryFullHarvestMode,
forecastCommitMode,
batteryPriorityMode,
},
nightEvcs: {
nightSessionActive,
nightSessionNeedsCapture,
nightSessionNeedsClear,
nightSessionStartSocPct,
nightSessionStartAt,
nightSessionStartMs,
batteryHardFloorPct,
batteryMorningTargetPct,
maxBatteryDischargePoints,
gridTakeoverTriggerPct,
batteryBelowHardFloor,
batteryBelowMorningTarget,
batteryAtOrBelowGridTakeoverTrigger,
batteryKwhMissingToTarget,
batteryRecoveryMinutesNeeded,
batteryRecoveryUrgent,
batteryRecoveryAllowedNow,
batteryTargetReached,
batteryAssistAllowed,
nightGridTakeoverNeeded,
nightPreferredSourceMode,
},
safeguards: {
isGridSource,
commonSafetyStartOk,
commonSafetyHoldOk,
profileSocStartFloor,
profileSocHoldFloor,
profileBatteryDischargeFloorA,
batteryCurrentHardExceeded,
mustStopHard,
gridBatteryProtectionStop,
},
conditions: {
rawSolarStartOk,
rawSolarHoldOk,
batteryAssistSolarPolicyEligible,
batteryAssistSolarStartOk,
batteryAssistSolarHoldOk,
solarStartOk,
solarHoldOk,
gridStartOk,
gridHoldOk,
batteryAssistNightStartOk,
batteryAssistNightHoldOk,
probeStartOk,
probeHoldGuardOk,
probeWindowActive,
probeElapsedReached,
probeSuccessNow,
probeFailedNow,
solarShouldStepUp,
gridShouldStepUp,
batteryAssistNightShouldStepUp,
solarShouldStepDownBattery,
solarShouldStepDownCloudDebt,
gridShouldStepDownBattery,
batteryAssistNightShouldStepDown,
shouldStepDownVebusPower,
shouldStepDownVebusVoltage,
shouldStepDownOverload,
shouldStopOverloadAtMin,
}
}
}];

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,32 @@
{{ {
"soc_percent": states("sensor.victron_battery_soc"),
"grid_state": states("switch.edf_general_ok_switch"),
"pv_power_w": states("sensor.victron_pv_power_system"),
"house_ac_power_w": states("sensor.victron_consumption_power_l1"),
"grid_power_w": states("sensor.victron_grid_power_l1"),
"evcs_ac_power_w": states("sensor.victron_evcs_ac_power"),
"evcs_charging": states("switch.victron_evcs_charging"),
"evcs_status_raw": states("sensor.victron_evcs_status_raw"),
"hysteresis_enabled": states("input_boolean.victron_grid_hysteresis_enabled"),
"notifications_enabled": states("input_boolean.victron_grid_hysteresis_notifications"),
"grid_latch": states("input_boolean.victron_grid_hysteresis_latch"),
"grid_soc_on": states("input_number.victron_grid_soc_on"),
"grid_soc_off": states("input_number.victron_grid_soc_off"),
"grid_min_on_minutes": states("input_number.victron_grid_min_on_minutes"),
"grid_cooldown_minutes": states("input_number.victron_grid_cooldown_minutes"),
"ac_loads_max_safe_w": states("input_number.victron_ac_loads_max_safe_w"),
"last_on_at": states("input_datetime.victron_grid_last_on_at"),
"last_off_at": states("input_datetime.victron_grid_last_off_at"),
"last_signature": states("input_text.victron_grid_control_last_signature"),
"evcs_priority_active": states("input_boolean.victron_evcs_priority_active"),
"night_grid_takeover_needed": states("input_boolean.victron_evcs_night_grid_takeover_needed"),
"evcs_source_mode": states("input_text.victron_evcs_source_mode"),
"user_strategy": states("input_select.victron_evcs_user_strategy"),
"evcs_surplus_state": states("input_text.victron_evcs_surplus_state")
} | tojson }}

View File

@@ -0,0 +1,579 @@
function minutesSince(ts, nowMs) {
if (ts === null || nowMs === null) return null;
return Math.max(0, (nowMs - ts) / 60000);
}
function secondsSince(ts, nowMs) {
if (ts === null || nowMs === null) return null;
return Math.max(0, (nowMs - ts) / 1000);
}
function isWithinWindow(nowMin, startMin, stopMin) {
if (!Number.isFinite(startMin) || !Number.isFinite(stopMin)) return false;
if (startMin === stopMin) return false;
if (startMin < stopMin) return nowMin >= startMin && nowMin < stopMin;
return nowMin >= startMin || nowMin < stopMin;
}
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
const prev = $input.first().json;
const {
input,
nowMs,
now,
cfg,
parsed,
params,
history,
batteryTune
} = prev;
// =====================================================
// STRATEGY
// =====================================================
const userStrategy = (params.userStrategyRaw ?? 'auto_solar').toString().trim().toLowerCase();
const isStopMode = userStrategy === 'stop';
const forceGridRequested = userStrategy === 'force_grid_now';
const forceChargeRequested = userStrategy === 'force_charge_now';
const isForceMode = forceGridRequested || forceChargeRequested;
// =====================================================
// LIFECYCLE / TIMING
// =====================================================
const minutesSinceStart = minutesSince(history.lastStartMs, nowMs);
const minutesSinceStop = minutesSince(history.lastStopMs, nowMs);
const secondsSinceCurrentChange = secondsSince(history.lastCurrentChangeMs, nowMs);
const minutesSinceProbeStart = minutesSince(history.probeStartedMs, nowMs);
const minRunElapsed =
minutesSinceStart === null ? true : minutesSinceStart >= params.minRunMinutes;
const minStopElapsed =
minutesSinceStop === null ? true : minutesSinceStop >= params.minStopMinutes;
const currentCooldownBaseSec = parsed.evcsChargingSwitchOn
? batteryTune.global.stepDownCooldownSeconds
: batteryTune.global.stepUpCooldownSeconds;
const currentCooldownElapsed =
secondsSinceCurrentChange === null
? true
: secondsSinceCurrentChange >= Math.max(
params.currentChangeCooldownSeconds,
currentCooldownBaseSec
);
// =====================================================
// DEPARTURE PRIORITY
// =====================================================
const minutesUntilDeparture =
params.departureTimeMin === null
? null
: (() => {
const rawDiff = params.departureTimeMin - now.minNow;
return rawDiff >= 0 ? rawDiff : (1440 - now.minNow + params.departureTimeMin);
})();
const departurePriorityActive =
parsed.departurePriorityEnabled &&
Number.isFinite(minutesUntilDeparture) &&
minutesUntilDeparture >= 0 &&
minutesUntilDeparture <= cfg.departurePriorityWindowMinutes &&
parsed.evcsVehicleConnected;
// =====================================================
// MANUAL CHARGE SEMANTICS
// IMPORTANT:
// force_charge_now = bypass fenêtres pour la charge,
// MAIS PAS demande implicite de réseau.
// =====================================================
const manualChargeBypassWindows = forceChargeRequested;
const manualChargeLocalRequested = forceChargeRequested && !forceGridRequested;
// Compatibilité ascendante : on garde le champ dans context,
// mais on le neutralise volontairement.
const forceChargeDepartureGridOverride = false;
// =====================================================
// SYSTEM DERIVED VALUES
// =====================================================
const acVoltageRef =
Number.isFinite(parsed.vebusAcOutVoltageV) && parsed.vebusAcOutVoltageV > 180
? parsed.vebusAcOutVoltageV
: cfg.nominalAcVoltageV;
const batteryVoltageRef =
Number.isFinite(parsed.vebusDcVoltageV) && parsed.vebusDcVoltageV > 0
? parsed.vebusDcVoltageV
: 51.2;
const houseNonEvcsW = Math.max(0, parsed.houseAcPowerW - parsed.evcsAcPowerW);
const vebusNonEvcsW = Math.max(0, parsed.vebusAcOutPowerW - parsed.evcsAcPowerW);
const totalVebusSoftCeilingW = Math.max(6500, params.acLoadsMaxSafeW || 8500);
const totalVebusHardCeilingW = totalVebusSoftCeilingW + 400;
const safeEvcsByDedicatedCapW = Math.max(0, cfg.evcsDedicatedMaxW - cfg.systemReserveW);
const safeEvcsByVebusW = Math.max(0, totalVebusSoftCeilingW - vebusNonEvcsW - cfg.systemReserveW);
const safeEvcsCombinedW = Math.max(0, Math.min(safeEvcsByDedicatedCapW, safeEvcsByVebusW));
const systemCapCurrentA = Math.max(0, Math.floor(safeEvcsCombinedW / acVoltageRef));
const physicalCapA = clamp(systemCapCurrentA, 0, params.currentMaxAutoA);
const canRunAtMinCurrent = physicalCapA >= params.currentMinA;
const endDayGuardSoc = Math.max(params.endOfDaySocGuard, params.endOfDaySocTarget);
const energyNeededForEndDayGuardKwh = Math.max(
0,
((endDayGuardSoc - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh
);
const forecastBufferKwh =
parsed.todayRemainingKwh -
params.baseLoadRemainingKwh -
params.flexibleSafetyMarginKwh -
params.requiredEnergyKwh -
energyNeededForEndDayGuardKwh;
const projectedEndSocAfterCharge = clamp(
parsed.soc + (
(
parsed.todayRemainingKwh -
params.baseLoadRemainingKwh -
params.flexibleSafetyMarginKwh -
params.requiredEnergyKwh
) / cfg.batteryUsableCapacityKwh
) * 100,
0,
100
);
const currentReferenceA = clamp(
Number.isFinite(parsed.evcsCommandCurrentA)
? parsed.evcsCommandCurrentA
: Number.isFinite(parsed.evcsSetCurrentA)
? parsed.evcsSetCurrentA
: params.targetCurrentAutoA,
0,
32
);
const anyVebusOverload = parsed.vebusOverload || parsed.vebusL1Overload;
const anyVebusLowBattery = parsed.vebusLowBattery || parsed.vebusL1LowBattery;
const vebusAcOutSoftReached = parsed.vebusAcOutPowerW >= totalVebusSoftCeilingW;
const vebusAcOutHardReached = parsed.vebusAcOutPowerW >= totalVebusHardCeilingW;
const vebusDcVoltageSoftLow =
Number.isFinite(parsed.vebusDcVoltageV) &&
parsed.vebusDcVoltageV > 0 &&
parsed.vebusDcVoltageV <= cfg.vebusDcVoltageSoftFloorV;
const vebusDcVoltageHardLow =
Number.isFinite(parsed.vebusDcVoltageV) &&
parsed.vebusDcVoltageV > 0 &&
parsed.vebusDcVoltageV <= cfg.vebusDcVoltageHardFloorV;
const houseLoadHighVsHeadroom = houseNonEvcsW > params.houseAcHeadroomW;
// =====================================================
// WINDOWS / PROFILE
// =====================================================
const inNightHcWindow = isWithinWindow(now.minNow, params.hcNightStartMin, params.hcNightStopMin);
const inMorningWindow = isWithinWindow(now.minNow, params.morningStartMin, params.morningStopMin);
const inMiddayWindow = isWithinWindow(now.minNow, params.hcMiddayStartMin, params.hcMiddayStopMin);
const inEveningWindow = isWithinWindow(now.minNow, params.eveningStartMin, params.eveningStopMin);
const profile =
inNightHcWindow ? 'night_hc' :
inMorningWindow ? 'morning_solar' :
inMiddayWindow ? 'midday_hc_or_solar' :
inEveningWindow ? 'evening_high_soc' :
'outside_window';
// =====================================================
// EVCS NIGHT PREVIEW ENGINE
// =====================================================
const nightSessionActive =
profile === 'night_hc' &&
parsed.evcsVehicleConnected;
const nightSessionStartSocPct =
nightSessionActive
? (
Number.isFinite(history.nightSessionStartSocPct)
? history.nightSessionStartSocPct
: parsed.soc
)
: null;
const nightSessionStartAt =
nightSessionActive
? (history.nightSessionStartAt || '')
: '';
const nightSessionNeedsCapture =
nightSessionActive &&
(
!Number.isFinite(history.nightSessionStartSocPct) ||
!history.nightSessionStartAt
);
const nightSessionNeedsClear =
!nightSessionActive &&
(
Number.isFinite(history.nightSessionStartSocPct) ||
!!history.nightSessionStartAt
);
const batteryHardFloorPct =
params.evcsNightBatteryHardFloorPct;
const batteryMorningTargetPct =
parsed.todayRemainingKwh >= params.evcsNightStrongForecastKwh
? params.evcsNightTargetStrongForecastPct
: parsed.todayRemainingKwh >= params.evcsNightMediumForecastKwh
? params.evcsNightTargetMediumForecastPct
: params.evcsNightTargetWeakForecastPct;
const maxBatteryDischargePoints =
nightSessionActive && Number.isFinite(nightSessionStartSocPct)
? Math.max(0, nightSessionStartSocPct - batteryMorningTargetPct)
: 0;
const gridTakeoverTriggerPct =
Math.max(batteryHardFloorPct, batteryMorningTargetPct);
const batteryBelowHardFloor =
parsed.soc <= batteryHardFloorPct;
const batteryBelowMorningTarget =
parsed.soc < batteryMorningTargetPct;
const batteryAtOrBelowGridTakeoverTrigger =
parsed.soc <= gridTakeoverTriggerPct;
const batteryKwhMissingToTarget =
nightSessionActive
? Math.max(
0,
((batteryMorningTargetPct - parsed.soc) / 100) * cfg.batteryUsableCapacityKwh
)
: 0;
const batteryRecoveryMinutesNeeded =
nightSessionActive && params.evcsNightBatteryRecoveryPowerKw > 0
? (batteryKwhMissingToTarget / params.evcsNightBatteryRecoveryPowerKw) * 60
: 0;
const batteryRecoveryUrgent =
nightSessionActive &&
Number.isFinite(minutesUntilDeparture) &&
minutesUntilDeparture >= 0 &&
(
batteryRecoveryMinutesNeeded + params.evcsNightDepartureSafetyMin
>= minutesUntilDeparture
);
const batteryAssistAllowed =
nightSessionActive &&
parsed.soc > batteryHardFloorPct &&
parsed.soc > gridTakeoverTriggerPct &&
!batteryRecoveryUrgent;
const nightGridTakeoverNeeded =
nightSessionActive &&
(
batteryBelowHardFloor ||
batteryAtOrBelowGridTakeoverTrigger ||
batteryRecoveryUrgent
);
const nightPreferredSourceMode =
!nightSessionActive
? 'blocked'
: batteryAssistAllowed
? 'battery_assist_night'
: (
batteryBelowHardFloor ||
batteryBelowMorningTarget ||
batteryRecoveryUrgent
)
? 'grid_forced'
: 'grid_hc_allowed';
// =====================================================
// PROBES / SOLAR POTENTIAL
// =====================================================
// IMPORTANT:
// On ne met plus effectivePvStartMinW à 0 en force_charge_now,
// sinon la nuit 0W >= 0W ferait croire à un faux signal solaire.
const effectivePvStartMinW = params.pvStartMinW;
const morningProbeEligible =
profile === 'morning_solar' &&
params.probeEnabled &&
parsed.pvPowerAvg10mW >= params.morningProbePvAvgMinW &&
parsed.pvTrendW >= params.morningProbePvTrendMinW &&
parsed.soc >= params.morningSocMin &&
parsed.batteryCurrentA >= params.morningBatteryMaxDischargeA;
const middayProbeEligible =
profile === 'midday_hc_or_solar' &&
params.probeEnabled &&
parsed.pvPowerAvg10mW >= params.middayProbePvAvgMinW &&
parsed.soc >= params.middaySocMin &&
parsed.batteryCurrentA >= params.middayBatteryMaxDischargeA;
const cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh;
const batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA);
const isHighBatteryForPotential =
parsed.soc >= 85 &&
forecastBufferKwh > 0 &&
cloudDebtOkForPotential &&
batteryOkForPotential;
const potentialAllowed = userStrategy !== 'auto_solar';
// IMPORTANT:
// On impose un vrai PV > 0 pour le test sur pvPowerW.
// Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire.
const strongSolarSignal =
(
parsed.pvPowerW > 0 &&
parsed.pvPowerW >= effectivePvStartMinW
) ||
parsed.pvPowerAvg10mW >= params.pvHoldMinW ||
(departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW);
// =====================================================
// SOURCE MODE
// SÉMANTIQUE CORRIGÉE:
// - force_charge_now bypass les fenêtres
// - mais ne devient jamais grid_forced à lui seul
// - outside_window = pas de réseau programmé
// - la charge locale peut rester autorisée
// =====================================================
let sourceMode = 'blocked';
if (isStopMode) {
sourceMode = 'blocked';
}
else if (forceGridRequested) {
// Demande explicite de réseau.
sourceMode = 'grid_forced';
}
else if (forceChargeRequested) {
// IMPORTANT:
// force_charge_now = intention de charger maintenant,
// sans exiger le réseau.
if (strongSolarSignal) {
sourceMode = 'solar_only';
}
else if (nightSessionActive && batteryAssistAllowed) {
sourceMode = 'battery_assist_night';
}
else if (isHighBatteryForPotential && potentialAllowed) {
sourceMode = 'solar_potential';
}
else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {
sourceMode = 'hybrid_probe';
}
else if (profile === 'morning_solar' && morningProbeEligible) {
sourceMode = 'hybrid_probe';
}
else {
// Fallback manuel local :
// on n'impose pas le grid, on laisse l'étage aval décider
// selon les garde-fous physiques / batterie.
sourceMode = 'solar_potential';
}
}
else if (profile === 'night_hc') {
if (nightSessionActive) {
sourceMode = nightPreferredSourceMode;
} else {
sourceMode = 'blocked';
}
}
else if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {
if (strongSolarSignal) {
sourceMode = 'solar_only';
}
else if (isHighBatteryForPotential && potentialAllowed) {
sourceMode = 'solar_potential';
}
else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {
sourceMode = 'hybrid_probe';
}
else if (profile === 'morning_solar' && morningProbeEligible) {
sourceMode = 'hybrid_probe';
}
else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {
sourceMode = 'grid_hc_allowed';
}
else {
sourceMode = 'blocked';
}
}
else if (profile === 'evening_high_soc') {
if (strongSolarSignal) {
sourceMode = 'solar_only';
}
else if (isHighBatteryForPotential && potentialAllowed) {
sourceMode = 'solar_potential';
}
else {
sourceMode = 'blocked';
}
}
else if (profile === 'outside_window') {
if (strongSolarSignal) {
sourceMode = 'solar_only';
}
else if (isHighBatteryForPotential && potentialAllowed) {
sourceMode = 'solar_potential';
}
// FIX V2.5: En auto_solar_plus, hors fenêtre, batteries hautes (>=85%),
// et peu de charge maison : permettre solar_potential pour provoquer le PV.
else if (userStrategy === 'auto_solar_plus' && parsed.soc >= 85 && houseNonEvcsW < 1500) {
sourceMode = 'solar_potential';
}
else {
// IMPORTANT:
// hors fenêtre = pas de réseau programmé.
// Donc on ne bascule plus vers grid_forced juste parce que le réseau existe.
sourceMode = 'blocked';
}
}
const operatingMode = profile;
// =====================================================
// OUTPUT
// =====================================================
return [{
json: {
...prev,
strategy: {
userStrategy,
isStopMode,
isForceMode,
forceGridRequested,
forceChargeRequested,
},
lifecycle: {
minutesSinceStart,
minutesSinceStop,
secondsSinceCurrentChange,
minutesSinceProbeStart,
minRunElapsed,
minStopElapsed,
currentCooldownElapsed,
minutesUntilDeparture,
},
windows: {
inNightHcWindow,
inMorningWindow,
inMiddayWindow,
inEveningWindow,
},
derived: {
acVoltageRef,
batteryVoltageRef,
houseNonEvcsW,
vebusNonEvcsW,
totalVebusSoftCeilingW,
totalVebusHardCeilingW,
safeEvcsByDedicatedCapW,
safeEvcsByVebusW,
safeEvcsCombinedW,
systemCapCurrentA,
physicalCapA,
canRunAtMinCurrent,
endDayGuardSoc,
energyNeededForEndDayGuardKwh,
forecastBufferKwh,
projectedEndSocAfterCharge,
currentReferenceA,
anyVebusOverload,
anyVebusLowBattery,
vebusAcOutSoftReached,
vebusAcOutHardReached,
vebusDcVoltageSoftLow,
vebusDcVoltageHardLow,
houseLoadHighVsHeadroom,
},
context: {
departurePriorityActive,
forceChargeDepartureGridOverride,
manualChargeBypassWindows,
manualChargeLocalRequested,
morningProbeEligible,
middayProbeEligible,
isHighBatteryForPotential,
strongSolarSignal,
effectivePvStartMinW,
profile,
operatingMode,
sourceMode,
},
nightEvcsPreview: {
nightSessionActive,
nightSessionNeedsCapture,
nightSessionNeedsClear,
nightSessionStartSocPct,
nightSessionStartAt,
batteryHardFloorPct,
batteryMorningTargetPct,
maxBatteryDischargePoints,
gridTakeoverTriggerPct,
batteryBelowHardFloor,
batteryBelowMorningTarget,
batteryAtOrBelowGridTakeoverTrigger,
batteryKwhMissingToTarget,
batteryRecoveryMinutesNeeded,
batteryRecoveryUrgent,
batteryAssistAllowed,
nightGridTakeoverNeeded,
nightPreferredSourceMode,
}
}
}];