Files
EVCS/archive/decoupesurplus/V2/gridhystP1.js
Tallagrand Thierry 1ae8129658 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.
2026-08-07 21:03:52 +02:00

234 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';