- src/nodes/*.js (6 nodes + template snapshot) = source de vérité unique. - n8n-push.mjs reconstruit le JSON depuis src/ avant syntaxe+golden+PUT (src gagne toujours; n8n-extract.mjs pour resynchroniser si édition UI). - n8n-build.mjs --check pour vérifier la synchro. - Copies historiques déplacées dans archive/ (mortes, dérivées de la prod). - .gitignore: backups locaux de rollback. - AGENTS.md: règle R2 pour toutes les futures sessions (dont Qwen local). - Validé: idempotence, mutation/restauration, pipeline push complet en réel.
587 lines
17 KiB
JavaScript
587 lines
17 KiB
JavaScript
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 afternoonProbeEligible =
|
|
profile === 'outside_window' &&
|
|
params.probeEnabled &&
|
|
parsed.soc >= params.afternoonProbeSocMin &&
|
|
parsed.batteryCurrentA >= params.afternoonBatteryMaxDischargeA &&
|
|
parsed.pvPowerAvg10mW >= params.afternoonProbePvAvgMinW;
|
|
|
|
const cloudDebtOkForPotential = params.cloudDebtPrevKwh <= params.cloudDebtMaxKwh;
|
|
const batteryOkForPotential = parsed.batteryCurrentA >= -Math.max(1, params.batteryNeutralBandA);
|
|
|
|
const isHighBatteryForPotential =
|
|
parsed.soc >= 85 &&
|
|
forecastBufferKwh > 0 &&
|
|
cloudDebtOkForPotential &&
|
|
batteryOkForPotential;
|
|
|
|
const potentialAllowed = userStrategy !== 'auto_solar';
|
|
|
|
// IMPORTANT:
|
|
// On impose un vrai PV > 0 pour le test sur pvPowerW.
|
|
// Ça évite qu'en force_charge_now la nuit, 0W soit pris pour un signal solaire.
|
|
const strongSolarSignal =
|
|
(
|
|
parsed.pvPowerW > 0 &&
|
|
parsed.pvPowerW >= effectivePvStartMinW
|
|
) ||
|
|
parsed.pvPowerAvg10mW >= params.pvHoldMinW ||
|
|
(departurePriorityActive && parsed.pvPowerAvg10mW >= params.pvHoldMinW);
|
|
|
|
// =====================================================
|
|
// SOURCE MODE
|
|
// SÉMANTIQUE CORRIGÉE:
|
|
// - force_charge_now bypass les fenêtres
|
|
// - mais ne devient jamais grid_forced à lui seul
|
|
// - outside_window = pas de réseau programmé
|
|
// - la charge locale peut rester autorisée
|
|
// =====================================================
|
|
let sourceMode = 'blocked';
|
|
|
|
if (isStopMode) {
|
|
sourceMode = 'blocked';
|
|
}
|
|
|
|
else if (forceGridRequested) {
|
|
// Demande explicite de réseau.
|
|
sourceMode = 'grid_forced';
|
|
}
|
|
|
|
else if (forceChargeRequested) {
|
|
// IMPORTANT:
|
|
// force_charge_now = intention de charger maintenant,
|
|
// sans exiger le réseau.
|
|
|
|
if (strongSolarSignal) {
|
|
sourceMode = 'solar_only';
|
|
}
|
|
|
|
else if (nightSessionActive && batteryAssistAllowed) {
|
|
sourceMode = 'battery_assist_night';
|
|
}
|
|
|
|
else if (isHighBatteryForPotential && potentialAllowed) {
|
|
sourceMode = 'solar_potential';
|
|
}
|
|
|
|
else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {
|
|
sourceMode = 'hybrid_probe';
|
|
}
|
|
|
|
else if (profile === 'morning_solar' && morningProbeEligible) {
|
|
sourceMode = 'hybrid_probe';
|
|
}
|
|
|
|
else {
|
|
// Fallback manuel local :
|
|
// on n'impose pas le grid, on laisse l'étage aval décider
|
|
// selon les garde-fous physiques / batterie.
|
|
sourceMode = 'solar_potential';
|
|
}
|
|
}
|
|
|
|
else if (profile === 'night_hc') {
|
|
if (nightSessionActive) {
|
|
sourceMode = nightPreferredSourceMode;
|
|
} else {
|
|
sourceMode = 'blocked';
|
|
}
|
|
}
|
|
|
|
else if (profile === 'midday_hc_or_solar' || profile === 'morning_solar') {
|
|
if (strongSolarSignal) {
|
|
sourceMode = 'solar_only';
|
|
}
|
|
|
|
else if (isHighBatteryForPotential && potentialAllowed) {
|
|
sourceMode = 'solar_potential';
|
|
}
|
|
|
|
else if (profile === 'midday_hc_or_solar' && middayProbeEligible) {
|
|
sourceMode = 'hybrid_probe';
|
|
}
|
|
|
|
else if (profile === 'morning_solar' && morningProbeEligible) {
|
|
sourceMode = 'hybrid_probe';
|
|
}
|
|
|
|
else if (profile === 'midday_hc_or_solar' && params.allowGridHcMidday && parsed.gridAvailable) {
|
|
sourceMode = 'grid_hc_allowed';
|
|
}
|
|
|
|
else {
|
|
sourceMode = 'blocked';
|
|
}
|
|
}
|
|
|
|
else if (profile === 'evening_high_soc') {
|
|
if (strongSolarSignal) {
|
|
sourceMode = 'solar_only';
|
|
}
|
|
|
|
else if (isHighBatteryForPotential && potentialAllowed) {
|
|
sourceMode = 'solar_potential';
|
|
}
|
|
|
|
else {
|
|
sourceMode = 'blocked';
|
|
}
|
|
}
|
|
|
|
else if (profile === 'outside_window') {
|
|
if (strongSolarSignal) {
|
|
sourceMode = 'solar_only';
|
|
}
|
|
|
|
else if (isHighBatteryForPotential && potentialAllowed) {
|
|
sourceMode = 'solar_potential';
|
|
}
|
|
|
|
else if (afternoonProbeEligible) {
|
|
sourceMode = 'hybrid_probe';
|
|
}
|
|
|
|
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,
|
|
afternoonProbeEligible,
|
|
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,
|
|
}
|
|
}
|
|
}];
|
|
|