Fix G: coupure EDF générique quand l'override EVCS disparaît mi-session
Incident nuit 21-22/08 : la fenêtre HC nuit se ferme pendant que le véhicule est encore en session active (pas encore flag "charge terminée"). L'EVCS relâche correctement son override, mais émet un état transitoire générique (stop_source_lost/idle_wait_source) qu'aucune règle de coupure immédiate du workflow Grid Hysteresis (Fix C/E2) ne reconnaît. EDF restait alors maintenu des heures par la boucle générique jusqu'au seuil statique gridSocOff, plafonnant le MPPT Victron à ~100W et privant la maison de solaire. Nouvelle règle générique (Compute Grid Hysteresis) basée sur evcsGridOverride plutôt que sur une liste blanche de noms de surplus_state — couvre donc aussi tout futur état non énuméré. Garde anti-glitch sur lecture HA invalide. Nouvelle suite tests/golden-hysteresis/ (7 scénarios, première pour ce workflow), câblée bloquante dans tools/n8n-push.mjs. Déployé et validé (golden-hysteresis 7/7, golden EVCS 39/39 inchangé). Écarte le diagnostic incomplet laissé par un autre agent dans HANDOFF.md (edf_general_ok_off côté EVCS aurait créé un deuxième écrivain non coordonné sur le même switch). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
32
tests/golden-hysteresis/fixture-snapshot.json
Normal file
32
tests/golden-hysteresis/fixture-snapshot.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"soc_percent": "50",
|
||||
"grid_state": "off",
|
||||
"pv_power_w": "2000",
|
||||
|
||||
"house_ac_power_w": "700",
|
||||
"grid_power_w": "0",
|
||||
|
||||
"evcs_ac_power_w": "0",
|
||||
"evcs_charging": "off",
|
||||
"evcs_status_raw": "6",
|
||||
|
||||
"hysteresis_enabled": "on",
|
||||
"notifications_enabled": "on",
|
||||
"grid_latch": "off",
|
||||
|
||||
"grid_soc_on": "22",
|
||||
"grid_soc_off": "35",
|
||||
"grid_min_on_minutes": "25",
|
||||
"grid_cooldown_minutes": "15",
|
||||
"ac_loads_max_safe_w": "7800",
|
||||
|
||||
"last_on_at": "",
|
||||
"last_off_at": "",
|
||||
"last_signature": "",
|
||||
|
||||
"evcs_priority_active": "off",
|
||||
"night_grid_takeover_needed": "off",
|
||||
"evcs_source_mode": "Bloqué",
|
||||
"user_strategy": "auto_solar_plus",
|
||||
"evcs_surplus_state": "idle_wait_conditions"
|
||||
}
|
||||
127
tests/golden-hysteresis/run.mjs
Normal file
127
tests/golden-hysteresis/run.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
// Suite de tests GOLDEN du workflow "Victron - Battery / Grid Hysteresis Fix V1".
|
||||
// Exécute le VRAI code du node "Compute Grid Hysteresis" sur un snapshot HA réel
|
||||
// modifié par scénario, avec horloge simulée.
|
||||
//
|
||||
// Usage:
|
||||
// node tests/golden-hysteresis/run.mjs # assertions (exit 1 si échec)
|
||||
// node tests/golden-hysteresis/run.mjs --print # affiche les résumés (pour figer les attendus)
|
||||
// node tests/golden-hysteresis/run.mjs --only=incident # filtre par id
|
||||
//
|
||||
// RÈGLE : cette suite DOIT passer avant tout push du workflow hystérésis
|
||||
// (intégrée à tools/n8n-push.mjs).
|
||||
|
||||
import fs from 'fs';
|
||||
import vm from 'vm';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { scenarios } from './scenarios.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const WORKFLOW_FILE = path.join(ROOT, 'workflows/victron-grid-hysteresis-v1.json');
|
||||
const FIXTURE_FILE = path.join(__dirname, 'fixture-snapshot.json');
|
||||
|
||||
const PRINT = process.argv.includes('--print');
|
||||
const JSON_OUT = process.argv.includes('--json');
|
||||
const only = (process.argv.find(a => a.startsWith('--only=')) || '').slice(7);
|
||||
|
||||
const wf = JSON.parse(fs.readFileSync(WORKFLOW_FILE, 'utf8'));
|
||||
const fixture = JSON.parse(fs.readFileSync(FIXTURE_FILE, 'utf8'));
|
||||
|
||||
const nodeCode = wf.nodes.find(n => n.name === 'Compute Grid Hysteresis').parameters.jsCode;
|
||||
|
||||
// Horloge simulée : Date épinglée à l'instant du scénario dans la sandbox.
|
||||
// Le node utilise new Date() (zéro argument) pour "maintenant" et
|
||||
// new Date(y, mo-1, d, h, mi, se) (multi-arguments) pour reparser last_on_at
|
||||
// /last_off_at — seuls les appels zéro-argument doivent être épinglés.
|
||||
function makeFakeDate(fixedMs) {
|
||||
return class FakeDate extends Date {
|
||||
constructor(...args) {
|
||||
if (args.length === 0) super(fixedMs);
|
||||
else super(...args);
|
||||
}
|
||||
static now() { return fixedMs; }
|
||||
};
|
||||
}
|
||||
|
||||
function runNode(jsCode, inputJson, fixedMs) {
|
||||
const ctx = {
|
||||
$input: { first: () => ({ json: inputJson }) },
|
||||
console, Intl, Math, Number, Object, String, JSON, Array, Boolean,
|
||||
Date: makeFakeDate(fixedMs),
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
const out = vm.runInContext(`(function(){\n${jsCode}\n})()`, ctx);
|
||||
return out[0].json;
|
||||
}
|
||||
|
||||
function summarize(out) {
|
||||
const c = out.computed || {};
|
||||
const d = out.decision || {};
|
||||
return {
|
||||
state: d.state,
|
||||
gridAction: d.grid_action,
|
||||
latchAction: d.latch_action,
|
||||
shouldNotify: !!d.should_notify,
|
||||
evcsGridOverride: !!c.evcs_grid_override,
|
||||
};
|
||||
}
|
||||
|
||||
const jsonResults = {};
|
||||
let failures = 0, ran = 0;
|
||||
for (const sc of scenarios) {
|
||||
if (only && !sc.id.includes(only)) continue;
|
||||
ran++;
|
||||
const fixedMs = new Date(sc.time).getTime();
|
||||
if (!Number.isFinite(fixedMs)) throw new Error(`${sc.id}: time invalide`);
|
||||
|
||||
const raw = { ...fixture, ...sc.overrides };
|
||||
const payload = { data: JSON.stringify(raw) };
|
||||
let out;
|
||||
try {
|
||||
out = runNode(nodeCode, payload, fixedMs);
|
||||
} catch (e) {
|
||||
failures++;
|
||||
console.log(`💥 ${sc.id} : CRASH — ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
const got = summarize(out);
|
||||
|
||||
if (JSON_OUT) {
|
||||
jsonResults[sc.id] = got;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (PRINT) {
|
||||
console.log(`\n─── ${sc.id} | ${sc.label}`);
|
||||
console.log(' expect:', JSON.stringify(got));
|
||||
if (out.decision?.short_reason) console.log(' raison:', out.decision.short_reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
const diffs = [];
|
||||
for (const [k, v] of Object.entries(sc.expect || {})) {
|
||||
if (JSON.stringify(got[k]) !== JSON.stringify(v)) diffs.push(`${k}: attendu ${JSON.stringify(v)}, obtenu ${JSON.stringify(got[k])}`);
|
||||
}
|
||||
if (diffs.length) {
|
||||
failures++;
|
||||
console.log(`❌ ${sc.id} | ${sc.label}`);
|
||||
for (const d of diffs) console.log(` ${d}`);
|
||||
if (out.decision?.short_reason) console.log(` raison: ${out.decision.short_reason}`);
|
||||
} else {
|
||||
console.log(`✅ ${sc.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (JSON_OUT) {
|
||||
console.log(JSON.stringify(jsonResults, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!PRINT) {
|
||||
console.log(failures
|
||||
? `\n${failures}/${ran} ÉCHEC(S) — NE PAS POUSSER EN PRODUCTION`
|
||||
: `\nGOLDEN HYSTERESIS OK — ${ran}/${ran} scénarios conformes`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
}
|
||||
168
tests/golden-hysteresis/scenarios.mjs
Normal file
168
tests/golden-hysteresis/scenarios.mjs
Normal file
@@ -0,0 +1,168 @@
|
||||
// Scénarios golden pour le node "Compute Grid Hysteresis"
|
||||
// (workflow "Victron - Battery / Grid Hysteresis Fix V1").
|
||||
//
|
||||
// Contexte (Fix G, 22/08/2026) : incident nuit 21-22/08 — la fenêtre HC nuit
|
||||
// se ferme pendant que le véhicule est encore en session active (pas encore
|
||||
// flag "charge terminée"). Le contrôleur EVCS relâche correctement son
|
||||
// override (helper night_grid_takeover_needed -> off, source_mode -> Bloqué)
|
||||
// mais émet un état transitoire générique (stop_source_lost / idle_wait_source)
|
||||
// qu'aucune règle de coupure immédiate existante (Fix C/E2) ne reconnaît.
|
||||
// EDF restait alors maintenu par la boucle générique jusqu'au seuil
|
||||
// grid_soc_off (35%), potentiellement des heures.
|
||||
|
||||
function stamp(ms) {
|
||||
const p = 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,
|
||||
}).formatToParts(new Date(ms));
|
||||
const g = t => p.find(x => x.type === t).value;
|
||||
return `${g('year')}-${g('month')}-${g('day')} ${g('hour')}:${g('minute')}:${g('second')}`;
|
||||
}
|
||||
|
||||
function minutesAgo(fixedMs, minutes) {
|
||||
return stamp(fixedMs - minutes * 60000);
|
||||
}
|
||||
|
||||
const T = '2026-08-22T06:15:00+02:00'; // fenêtre HC (01:24-07:24) fermée mi-session
|
||||
const T_MS = new Date(T).getTime();
|
||||
|
||||
export const scenarios = [
|
||||
{
|
||||
id: 'g-incident-gap-mid-session',
|
||||
label: 'Fenêtre fermée mi-session (stop_source_lost) — min_on NON écoulé',
|
||||
time: T,
|
||||
overrides: {
|
||||
grid_latch: 'on',
|
||||
grid_state: 'on',
|
||||
soc_percent: '30',
|
||||
night_grid_takeover_needed: 'off',
|
||||
evcs_source_mode: 'Bloqué',
|
||||
evcs_surplus_state: 'stop_source_lost',
|
||||
user_strategy: 'auto_solar_plus',
|
||||
last_on_at: minutesAgo(T_MS, 5), // << 25 min de min_on
|
||||
},
|
||||
expect: {
|
||||
state: 'turn_off_evcs_override_released',
|
||||
gridAction: 'turn_off',
|
||||
latchAction: 'turn_off',
|
||||
evcsGridOverride: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'g-incident-gap-idle-wait-source',
|
||||
label: 'Fenêtre fermée mi-session (idle_wait_source) — même bypass générique',
|
||||
time: T,
|
||||
overrides: {
|
||||
grid_latch: 'on',
|
||||
grid_state: 'on',
|
||||
soc_percent: '30',
|
||||
night_grid_takeover_needed: 'off',
|
||||
evcs_source_mode: 'Bloqué',
|
||||
evcs_surplus_state: 'idle_wait_source',
|
||||
user_strategy: 'auto_solar_plus',
|
||||
last_on_at: minutesAgo(T_MS, 5),
|
||||
},
|
||||
expect: {
|
||||
state: 'turn_off_evcs_override_released',
|
||||
gridAction: 'turn_off',
|
||||
latchAction: 'turn_off',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'g-baseline-low-soc-no-override',
|
||||
label: 'SOC bas indépendant (18% <= seuil ON 22%) — Fix G ne doit PAS agir',
|
||||
time: T,
|
||||
overrides: {
|
||||
grid_latch: 'on',
|
||||
grid_state: 'on',
|
||||
soc_percent: '18',
|
||||
night_grid_takeover_needed: 'off',
|
||||
evcs_source_mode: '',
|
||||
evcs_surplus_state: '',
|
||||
user_strategy: 'auto_solar_plus',
|
||||
last_on_at: minutesAgo(T_MS, 5),
|
||||
},
|
||||
expect: {
|
||||
state: 'hold_on_low_soc',
|
||||
gridAction: 'none',
|
||||
latchAction: 'none',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'g-regression-fixc-unplugged',
|
||||
label: 'Véhicule débranché — Fix C doit toujours gagner (pas absorbé par Fix G)',
|
||||
time: T,
|
||||
overrides: {
|
||||
grid_latch: 'on',
|
||||
grid_state: 'on',
|
||||
soc_percent: '40',
|
||||
night_grid_takeover_needed: 'off',
|
||||
evcs_source_mode: 'Bloqué',
|
||||
evcs_surplus_state: 'vehicle_not_connected',
|
||||
user_strategy: 'auto_solar_plus',
|
||||
last_on_at: minutesAgo(T_MS, 5),
|
||||
},
|
||||
expect: {
|
||||
state: 'turn_off_vehicle_unplugged',
|
||||
gridAction: 'turn_off',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'g-regression-fixe2-recovery-done',
|
||||
label: 'Recovery done — Fix E2 doit toujours gagner (pas absorbé par Fix G)',
|
||||
time: T,
|
||||
overrides: {
|
||||
grid_latch: 'on',
|
||||
grid_state: 'on',
|
||||
soc_percent: '40',
|
||||
night_grid_takeover_needed: 'off',
|
||||
evcs_source_mode: 'Bloqué',
|
||||
evcs_surplus_state: 'night_recovery_done_idle',
|
||||
user_strategy: 'auto_solar_plus',
|
||||
last_on_at: minutesAgo(T_MS, 5),
|
||||
},
|
||||
expect: {
|
||||
state: 'turn_off_recovery_done',
|
||||
gridAction: 'turn_off',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'g-incident-replay-hc-boundary',
|
||||
label: 'Rejeu incident complet : min_on déjà écoulé aussi — pas seulement un bypass min_on',
|
||||
time: T,
|
||||
overrides: {
|
||||
grid_latch: 'on',
|
||||
grid_state: 'on',
|
||||
soc_percent: '30',
|
||||
night_grid_takeover_needed: 'off',
|
||||
evcs_source_mode: 'Bloqué',
|
||||
evcs_surplus_state: 'stop_source_lost',
|
||||
user_strategy: 'auto_solar_plus',
|
||||
last_on_at: minutesAgo(T_MS, 120), // min_on largement écoulé
|
||||
},
|
||||
expect: {
|
||||
state: 'turn_off_evcs_override_released',
|
||||
gridAction: 'turn_off',
|
||||
latchAction: 'turn_off',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'g-glitch-guard-unavailable',
|
||||
label: 'Lecture HA source_mode invalide — garde anti-glitch, Fix G ne doit PAS agir',
|
||||
time: T,
|
||||
overrides: {
|
||||
grid_latch: 'on',
|
||||
grid_state: 'on',
|
||||
soc_percent: '30',
|
||||
night_grid_takeover_needed: 'off',
|
||||
evcs_source_mode: 'unavailable',
|
||||
evcs_surplus_state: 'stop_source_lost',
|
||||
user_strategy: 'auto_solar_plus',
|
||||
last_on_at: minutesAgo(T_MS, 5),
|
||||
},
|
||||
expect: {
|
||||
state: 'hold_on_min_runtime',
|
||||
gridAction: 'none',
|
||||
},
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user