- 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.
236 lines
6.5 KiB
JavaScript
236 lines
6.5 KiB
JavaScript
function clamp(v, min, max) {
|
|
return Math.min(Math.max(v, min), max);
|
|
}
|
|
|
|
function to2(n) {
|
|
return String(n).padStart(2, '0');
|
|
}
|
|
|
|
function getParisParts(ms = Date.now()) {
|
|
const fmt = new Intl.DateTimeFormat('fr-FR', {
|
|
timeZone: 'Europe/Paris',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
});
|
|
|
|
const parts = Object.fromEntries(
|
|
fmt.formatToParts(new Date(ms))
|
|
.filter(p => p.type !== 'literal')
|
|
.map(p => [p.type, p.value])
|
|
);
|
|
|
|
const hour = Number(parts.hour) === 24 ? 0 : Number(parts.hour);
|
|
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
|
|
|
return {
|
|
date,
|
|
hour,
|
|
minute: Number(parts.minute),
|
|
second: Number(parts.second),
|
|
};
|
|
}
|
|
|
|
function getTzOffsetMs(utcTs, timeZone = 'Europe/Paris') {
|
|
const dtf = new Intl.DateTimeFormat('en-US', {
|
|
timeZone,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
});
|
|
|
|
const parts = Object.fromEntries(
|
|
dtf.formatToParts(new Date(utcTs))
|
|
.filter(p => p.type !== 'literal')
|
|
.map(p => [p.type, p.value])
|
|
);
|
|
|
|
const asIfUtc = Date.UTC(
|
|
Number(parts.year),
|
|
Number(parts.month) - 1,
|
|
Number(parts.day),
|
|
Number(parts.hour) === 24 ? 0 : Number(parts.hour),
|
|
Number(parts.minute),
|
|
Number(parts.second)
|
|
);
|
|
|
|
const d = new Date(utcTs);
|
|
const actualUtc = Date.UTC(
|
|
d.getUTCFullYear(),
|
|
d.getUTCMonth(),
|
|
d.getUTCDate(),
|
|
d.getUTCHours(),
|
|
d.getUTCMinutes(),
|
|
d.getUTCSeconds()
|
|
);
|
|
|
|
return asIfUtc - actualUtc;
|
|
}
|
|
|
|
function parseParisStampToMs(s) {
|
|
const text = (s ?? '').toString().trim();
|
|
const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
|
|
if (!m) return null;
|
|
|
|
const y = Number(m[1]);
|
|
const mo = Number(m[2]);
|
|
const d = Number(m[3]);
|
|
const h = Number(m[4]);
|
|
const mi = Number(m[5]);
|
|
const se = Number(m[6]);
|
|
|
|
const guessUtc = Date.UTC(y, mo - 1, d, h, mi, se);
|
|
const off1 = getTzOffsetMs(guessUtc, 'Europe/Paris');
|
|
let ts = guessUtc - off1;
|
|
const off2 = getTzOffsetMs(ts, 'Europe/Paris');
|
|
|
|
if (off2 !== off1) ts = guessUtc - off2;
|
|
return ts;
|
|
}
|
|
|
|
function hhmmssFromMinutes(minOfDay) {
|
|
const mm = ((minOfDay % 1440) + 1440) % 1440;
|
|
const hh = Math.floor(mm / 60);
|
|
const mi = mm % 60;
|
|
return `${to2(hh)}:${to2(mi)}:00`;
|
|
}
|
|
|
|
function nextDailyOccurrenceMs(startMin, refMs) {
|
|
if (!Number.isFinite(startMin)) return null;
|
|
|
|
const today = getParisParts(refMs).date;
|
|
const todayStamp = `${today} ${hhmmssFromMinutes(startMin)}`;
|
|
const todayMs = parseParisStampToMs(todayStamp);
|
|
|
|
if (todayMs !== null && todayMs > refMs + 1000) return todayMs;
|
|
|
|
const tomorrow = getParisParts(refMs + 36 * 3600 * 1000).date;
|
|
const tomorrowStamp = `${tomorrow} ${hhmmssFromMinutes(startMin)}`;
|
|
return parseParisStampToMs(tomorrowStamp);
|
|
}
|
|
|
|
const prev = $input.first().json;
|
|
|
|
const {
|
|
nowMs,
|
|
cfg,
|
|
parsed,
|
|
params,
|
|
history,
|
|
batteryTune,
|
|
strategy,
|
|
derived,
|
|
context,
|
|
lifecycle,
|
|
} = prev;
|
|
|
|
// =====================================================
|
|
// SESSION / SOURCE
|
|
// =====================================================
|
|
const sessionObservedActive =
|
|
parsed.evcsChargingSwitchOn ||
|
|
parsed.controlLatch ||
|
|
parsed.chargePermissionNow;
|
|
|
|
const isGridSource =
|
|
context.sourceMode === 'grid_hc_allowed' ||
|
|
context.sourceMode === 'grid_forced';
|
|
|
|
// =====================================================
|
|
// SOC BAND / BATTERY DYNAMIC POLICY
|
|
// =====================================================
|
|
const socBand =
|
|
parsed.soc > 90 ? 'gt90' :
|
|
parsed.soc > 80 ? '80_90' :
|
|
parsed.soc > 60 ? '60_80' :
|
|
parsed.soc > 40 ? '40_60' :
|
|
parsed.soc > 25 ? '25_40' :
|
|
'lt25';
|
|
|
|
const band = batteryTune.bands[socBand];
|
|
|
|
const dischargeModeFactor =
|
|
strategy.forceChargeRequested ? 1.2 :
|
|
strategy.forceGridRequested ? 0.7 :
|
|
strategy.userStrategy === 'auto_solar' ? 0.8 :
|
|
1.0;
|
|
|
|
const chargeReserveModeFactor =
|
|
strategy.forceChargeRequested ? 0.8 :
|
|
strategy.forceGridRequested ? 1.1 :
|
|
strategy.userStrategy === 'auto_solar' ? 1.1 :
|
|
1.0;
|
|
|
|
const toleranceModeFactor =
|
|
strategy.forceChargeRequested ? 1.25 :
|
|
strategy.forceGridRequested ? 0.8 :
|
|
strategy.userStrategy === 'auto_solar' ? 0.8 :
|
|
1.0;
|
|
|
|
const batteryDischargeSoftDynamicA = band.dischargeSoftA * dischargeModeFactor;
|
|
const batteryDischargeHardDynamicA = band.dischargeHardA * dischargeModeFactor;
|
|
const batteryChargeReserveDynamicA = Math.max(0, band.chargeReserveA * chargeReserveModeFactor);
|
|
const batterySoftToleranceMinutes = band.softToleranceMin * toleranceModeFactor;
|
|
const batteryHardToleranceSeconds = band.hardToleranceSec;
|
|
const batteryRecoveryMinutes = band.recoveryMin;
|
|
|
|
// =====================================================
|
|
// CONTEXT HELPERS
|
|
// =====================================================
|
|
const houseSpikeThresholdW = 1500;
|
|
const houseSpikeDetected =
|
|
(parsed.houseAcPowerW - parsed.houseAcPowerPrevW) >= houseSpikeThresholdW;
|
|
|
|
const solarPotentialContext =
|
|
context.sourceMode === 'solar_potential' ||
|
|
(
|
|
parsed.soc >= 80 &&
|
|
derived.forecastBufferKwh > 0 &&
|
|
parsed.pvPowerW < context.effectivePvStartMinW &&
|
|
derived.houseNonEvcsW < 1200
|
|
);
|
|
|
|
// =====================================================
|
|
// REASON-BASED LOCKS
|
|
// =====================================================
|
|
const nextWindowCandidates = [
|
|
nextDailyOccurrenceMs(params.morningStartMin, nowMs),
|
|
nextDailyOccurrenceMs(params.hcMiddayStartMin, nowMs),
|
|
nextDailyOccurrenceMs(params.eveningStartMin, nowMs),
|
|
nextDailyOccurrenceMs(params.hcNightStartMin, nowMs),
|
|
].filter(v => Number.isFinite(v));
|
|
|
|
const nextAllowedWindowStartMs =
|
|
nextWindowCandidates.length > 0 ? Math.min(...nextWindowCandidates) : null;
|
|
|
|
const blockActive =
|
|
history.blockedUntilMs !== null &&
|
|
history.blockedUntilMs > nowMs + 1000;
|
|
|
|
const blockStillRelevant =
|
|
history.lastBlockReason === 'outside_window'
|
|
? context.profile === 'outside_window'
|
|
: history.lastBlockReason === 'probe_failed'
|
|
? context.sourceMode === 'hybrid_probe'
|
|
: history.lastBlockReason === 'battery_protection_stop'
|
|
? (
|
|
parsed.batteryCurrentA < -params.batteryNeutralBandA ||
|
|
derived.anyVebusLowBattery ||
|
|
derived.vebusDcVoltageSoftLow
|
|
)
|
|
: history.lastBlockReason === 'grid_battery_guard'
|
|
? isGridSource
|
|
: history.lastBlockReason === 'vebus_overload_persistent'
|
|
? derived.anyVebusOverload
|
|
: false;
|
|
|
|
const startBlockedByReason = blockActive && blockStillRelevant;
|