R2: git = source de vérité — src/nodes/ + build/extract intégrés au push
- src/nodes/*.js (6 nodes + template snapshot) = source de vérité unique. - n8n-push.mjs reconstruit le JSON depuis src/ avant syntaxe+golden+PUT (src gagne toujours; n8n-extract.mjs pour resynchroniser si édition UI). - n8n-build.mjs --check pour vérifier la synchro. - Copies historiques déplacées dans archive/ (mortes, dérivées de la prod). - .gitignore: backups locaux de rollback. - AGENTS.md: règle R2 pour toutes les futures sessions (dont Qwen local). - Validé: idempotence, mutation/restauration, pipeline push complet en réel.
This commit is contained in:
233
archive/decoupesurplus/V2/gridhystP1.js
Normal file
233
archive/decoupesurplus/V2/gridhystP1.js
Normal file
@@ -0,0 +1,233 @@
|
||||
function num(v, fallback = NaN) {
|
||||
const n = Number.parseFloat(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function low(v) {
|
||||
return (v ?? '').toString().trim().toLowerCase();
|
||||
}
|
||||
|
||||
function parseMaybeJson(v) {
|
||||
if (typeof v !== 'string') return v;
|
||||
try {
|
||||
return JSON.parse(v);
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
function extractSnapshot(input) {
|
||||
if (typeof input === 'string') return parseMaybeJson(input);
|
||||
if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);
|
||||
if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);
|
||||
if (input?.data && typeof input.data === 'object') return input.data;
|
||||
if (input?.body && typeof input.body === 'object') return input.body;
|
||||
return input;
|
||||
}
|
||||
|
||||
function round1(v) {
|
||||
return Math.round(v * 10) / 10;
|
||||
}
|
||||
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(Math.max(v, min), max);
|
||||
}
|
||||
|
||||
function getParisNowParts() {
|
||||
const fmt = new Intl.DateTimeFormat('fr-FR', {
|
||||
timeZone: 'Europe/Paris',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const parts = Object.fromEntries(
|
||||
fmt.formatToParts(new Date())
|
||||
.filter(p => p.type !== 'literal')
|
||||
.map(p => [p.type, p.value])
|
||||
);
|
||||
|
||||
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
||||
const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;
|
||||
|
||||
return {
|
||||
year: Number(parts.year),
|
||||
month: Number(parts.month),
|
||||
day: Number(parts.day),
|
||||
hour: Number(parts.hour),
|
||||
minute: Number(parts.minute),
|
||||
second: Number(parts.second),
|
||||
date,
|
||||
stamp,
|
||||
};
|
||||
}
|
||||
|
||||
function parseLocalStampToMs(s) {
|
||||
const text = (s ?? '').toString().trim();
|
||||
const m = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
|
||||
if (!m) return null;
|
||||
|
||||
const y = Number(m[1]);
|
||||
const mo = Number(m[2]);
|
||||
const d = Number(m[3]);
|
||||
const h = Number(m[4]);
|
||||
const mi = Number(m[5]);
|
||||
const se = Number(m[6]);
|
||||
|
||||
if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;
|
||||
|
||||
return new Date(y, mo - 1, d, h, mi, se).getTime();
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
mobileNotifyDomain: 'notify',
|
||||
mobileNotifyService: 'mobile_app_iphone_16_de_tt'
|
||||
};
|
||||
|
||||
const raw = $input.first().json;
|
||||
const input = extractSnapshot(raw);
|
||||
const now = getParisNowParts();
|
||||
const nowMs = parseLocalStampToMs(now.stamp);
|
||||
|
||||
const soc = clamp(num(input.soc_percent, 0), 0, 100);
|
||||
const gridIsOn = low(input.grid_state || 'off') === 'on';
|
||||
const pvPowerW = Math.max(0, num(input.pv_power_w, 0));
|
||||
|
||||
const houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));
|
||||
const gridPowerW = num(input.grid_power_w, 0);
|
||||
|
||||
const evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));
|
||||
const evcsCharging = low(input.evcs_charging || 'off') === 'on';
|
||||
const evcsStatusRaw = num(input.evcs_status_raw, NaN);
|
||||
|
||||
const hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';
|
||||
const notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';
|
||||
const latchIsOn = low(input.grid_latch || 'off') === 'on';
|
||||
|
||||
const gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);
|
||||
const gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);
|
||||
const gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));
|
||||
const gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));
|
||||
const acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));
|
||||
|
||||
const lastOnAt = (input.last_on_at ?? '').toString().trim();
|
||||
const lastOffAt = (input.last_off_at ?? '').toString().trim();
|
||||
const lastSignature = (input.last_signature ?? '').toString().trim();
|
||||
|
||||
const lastOnMs = parseLocalStampToMs(lastOnAt);
|
||||
const lastOffMs = parseLocalStampToMs(lastOffAt);
|
||||
|
||||
const minutesSinceLastOn =
|
||||
lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;
|
||||
|
||||
const minutesSinceLastOff =
|
||||
lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;
|
||||
|
||||
const minOnElapsed =
|
||||
minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;
|
||||
|
||||
const cooldownElapsed =
|
||||
minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;
|
||||
|
||||
const socBand =
|
||||
soc <= gridSocOn ? 'low' :
|
||||
soc < gridSocOff ? 'mid' :
|
||||
'high';
|
||||
|
||||
const evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;
|
||||
const acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;
|
||||
|
||||
let state = 'observe_only';
|
||||
let reason = [];
|
||||
let desiredGrid = gridIsOn ? 'on' : 'off';
|
||||
let desiredLatch = latchIsOn ? 'on' : 'off';
|
||||
|
||||
// ======================================================
|
||||
// Core hysteresis logic V1.1
|
||||
// ======================================================
|
||||
if (!hysteresisEnabled) {
|
||||
state = 'hysteresis_disabled';
|
||||
desiredGrid = gridIsOn ? 'on' : 'off';
|
||||
desiredLatch = latchIsOn ? 'on' : 'off';
|
||||
reason.push('Automatisme hystérésis réseau désactivé');
|
||||
|
||||
} else if (soc <= gridSocOn) {
|
||||
// Seuil bas atteint => prise de main immédiate
|
||||
desiredGrid = 'on';
|
||||
desiredLatch = 'on';
|
||||
|
||||
if (!cooldownElapsed && !gridIsOn) {
|
||||
state = 'turn_on_low_soc_cooldown_bypassed';
|
||||
reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);
|
||||
} else if (!gridIsOn) {
|
||||
state = 'turn_on_low_soc';
|
||||
reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);
|
||||
} else if (!latchIsOn) {
|
||||
state = 'adopt_existing_grid_on_low_soc';
|
||||
reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);
|
||||
} else {
|
||||
state = 'hold_on_low_soc';
|
||||
reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);
|
||||
}
|
||||
|
||||
} else if (gridIsOn && !latchIsOn) {
|
||||
// NOUVEAU CAS V1.1 :
|
||||
// Le réseau est déjà ON mais ce workflow n'a pas la main.
|
||||
if (soc < gridSocOff) {
|
||||
desiredGrid = 'on';
|
||||
desiredLatch = 'on';
|
||||
state = 'adopt_existing_grid_on';
|
||||
reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);
|
||||
} else {
|
||||
desiredGrid = 'off';
|
||||
desiredLatch = 'off';
|
||||
state = 'turn_off_stray_grid_on';
|
||||
reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);
|
||||
}
|
||||
|
||||
} else if (latchIsOn) {
|
||||
// Tant que le latch est actif, on garde la main
|
||||
if (!minOnElapsed) {
|
||||
desiredGrid = 'on';
|
||||
desiredLatch = 'on';
|
||||
state = 'hold_on_min_runtime';
|
||||
reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);
|
||||
} else if (soc < gridSocOff) {
|
||||
desiredGrid = 'on';
|
||||
desiredLatch = 'on';
|
||||
state = 'hold_on_until_soc_off';
|
||||
reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);
|
||||
} else {
|
||||
desiredGrid = 'off';
|
||||
desiredLatch = 'off';
|
||||
state = 'turn_off_recovered';
|
||||
reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);
|
||||
}
|
||||
|
||||
} else {
|
||||
desiredGrid = 'off';
|
||||
desiredLatch = 'off';
|
||||
state = 'observe_only';
|
||||
reason.push('Aucune action : hors zone d’hystérésis');
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// Compare desired vs current
|
||||
// ======================================================
|
||||
const gridAction =
|
||||
desiredGrid === (gridIsOn ? 'on' : 'off')
|
||||
? 'none'
|
||||
: desiredGrid === 'on'
|
||||
? 'turn_on'
|
||||
: 'turn_off';
|
||||
|
||||
const latchAction =
|
||||
desiredLatch === (latchIsOn ? 'on' : 'off')
|
||||
? 'none'
|
||||
: desiredLatch === 'on'
|
||||
? 'turn_on'
|
||||
: 'turn_off';
|
||||
Reference in New Issue
Block a user