Files
EVCS/archive/HYSTERESIS/COMPUTE.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

626 lines
20 KiB
JavaScript
Raw Permalink 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 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
// Si EVCS dit déjà "plus de demande", on relâche l'override.
// ======================================================
const evcsGridOverride =
!evcsVehicleNoDemandSemantic &&
(
userStrategy === 'force_grid_now' ||
userStrategy === 'force_charge_now' ||
evcsPriorityActive ||
evcsNightGridTakeoverNeeded ||
evcsSourceMode === 'réseau forcé' ||
evcsSourceMode === 'réseau hc autorisé' ||
evcsSourceMode === 'grid_forced' ||
evcsSourceMode === 'grid_hc_allowed'
);
// ======================================================
// DÉCISION
// ======================================================
let state = 'observe_only';
let reason = [];
let desiredGrid = gridIsOn ? 'on' : 'off';
let desiredLatch = latchIsOn ? 'on' : 'off';
// ======================================================
// 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 (evcsPriorityActive) {
reason.push('Cause override : evcs_priority_active');
} 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 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'}`,
`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_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 }];