correction sur la logique
This commit is contained in:
1
EVCS
Submodule
1
EVCS
Submodule
Submodule EVCS added at 874aca1afa
625
HYSTERESIS/COMPUTE.js
Normal file
625
HYSTERESIS/COMPUTE.js
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
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 }];
|
||||||
|
|
||||||
198
HYSTERESIS/COMPUTEP1.js
Normal file
198
HYSTERESIS/COMPUTEP1.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
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);
|
||||||
201
HYSTERESIS/COMPUTEP2.js
Normal file
201
HYSTERESIS/COMPUTEP2.js
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// ======================================================
|
||||||
|
// 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'}`);
|
||||||
224
HYSTERESIS/COMPUTEP3.js
Normal file
224
HYSTERESIS/COMPUTEP3.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
// ======================================================
|
||||||
|
// 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 }];
|
||||||
|
|
||||||
Reference in New Issue
Block a user