- 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.
202 lines
7.3 KiB
JavaScript
202 lines
7.3 KiB
JavaScript
// ======================================================
|
||
// 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'}`);
|