§26 avait pose l objectif et designe le critere. Avant d ecrire quoi que ce soit, j ai mesure les kWh reellement sortis de la batterie par point de SOC sur cinq fenetres et trois nuits : 0,164 a 0,171 kWh/point au-dessus de 42 %, soit environ 17 kWh, mais 0,121 entre 45 et 22 % et 0,082 entre 33 et 20 %. En bas d echelle un point de SOC vaut moitie moins, alors que BALANCE.js convertit avec une constante unique de 15 kWh. required_soc y est donc systematiquement trop bas — c est le facteur 1,75 mesure la nuit du 02-03/09, ou le SOC descendait bien plus vite que la ligne. Cette mesure a dicte la conception : le relachement de la session ne peut pas etre un seuil de SOC, puisqu il porterait sur une grandeur fausse et que tout seuil que le SOC repasse ensuite rouvre l oscillateur du 27/08. Les deux variantes evidentes — relacher sur required_soc, ou sur required_soc_prudent avec la bande p50/p80 — ont ete ecartees pour cette raison. Retenu : engagement sur le bilan (batterie sous la cible), relachement a la fin de la fenetre. L oscillation devient structurellement impossible, la condition de sortie etant monotone : une fenetre ne se ferme qu une fois. Un nouveau helper input_boolean.victron_evcs_night_grid_session_committed porte l engagement, cree par input_boolean.reload sans redemarrage HA et ecrit seulement sur changement reel ; sans cette persistance la garde redeviendrait une bande, donc un oscillateur. Effet : l ACM recharge jusqu a la cible puis tient la batterie a 2 A, la maison finit la nuit sur le reseau en heures creuses au lieu de vider la reserve, et la batterie arrive a l aube pres de sa cible avec de la place pour le solaire. La coupure immediate E2 de l hysteresis exige l absence d override et le helper takeover reste ON : aucune modification du workflow d hysteresis. Golden 75 -> 78. Le scenario epingle quelques heures plus tot comme limite connue devient celui du correctif, attendu mis a jour dans le meme commit. Quatre mutations ; la plus parlante retire la garde bilan et fait tomber les trois scenarios anti-oscillateur du 27/08 — les tests de l ancien correctif protegent le nouveau code. Prerequis identifie et non traite : la conversion kWh vers points de SOC est une constante la ou la realite est une courbe. Consequence, un sous-declenchement possible en bas d echelle, jamais une oscillation. Prochain chantier du bilan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
186 lines
6.4 KiB
JavaScript
186 lines
6.4 KiB
JavaScript
#!/usr/bin/env node
|
|
// R3 — Suite de tests GOLDEN du workflow EVCS Surplus Controller V2.6.
|
|
// Exécute le VRAI code des nodes (chaîne complète INPUT_PARSE → WINDOWS_SOURCEV2
|
|
// → BATTERY_CLOUD_LOCKS → DECISION_ENGINEV2 → HA_SERVICE_CALLS_OUTPUT → Build)
|
|
// sur un snapshot HA réel modifié par scénario, avec horloge simulée.
|
|
//
|
|
// Usage:
|
|
// node tests/golden/run.mjs # assertions (exit 1 si échec)
|
|
// node tests/golden/run.mjs --print # affiche les résumés (pour figer les attendus)
|
|
// node tests/golden/run.mjs --only=night-hc # filtre par label
|
|
//
|
|
// RÈGLE : cette suite DOIT passer avant tout push (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/evcs-surplus-controller-v2.6.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 CHAIN = [
|
|
'INPUT_PARSE',
|
|
'WINDOWS_SOURCEV2',
|
|
'BATTERY_CLOUD_LOCKS',
|
|
'DECISION_ENGINEV2',
|
|
'HA_SERVICE_CALLS_OUTPUT',
|
|
];
|
|
const nodeCode = Object.fromEntries(
|
|
CHAIN.map(n => [n, wf.nodes.find(x => x.name === n).parameters.jsCode])
|
|
);
|
|
|
|
// Horloge simulée : Date épinglée à l'instant du scénario dans la sandbox.
|
|
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;
|
|
}
|
|
|
|
// Overrides par défaut : timers neutres relatifs à l'instant du scénario
|
|
// (min_stop écoulé, pas de blocage anti-cliquetis, filtre no-demand au repos).
|
|
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 baseOverrides(fixedMs) {
|
|
return {
|
|
last_start_at: stamp(fixedMs - 2 * 3600000),
|
|
last_stop_at: stamp(fixedMs - 2 * 3600000 + 30000),
|
|
last_current_change_at: stamp(fixedMs - 2 * 3600000),
|
|
blocked_until: stamp(fixedMs - 3600000),
|
|
last_block_reason: 'none',
|
|
evcs_no_demand_filter_eval_at: stamp(fixedMs - 30000),
|
|
evcs_no_demand_semantic_active: 'off',
|
|
evcs_delivery_ratio_ema: '0.9',
|
|
evcs_delivery_noise_ema: '0.01',
|
|
evcs_no_demand_score_ema: '0.0',
|
|
probe_started_at: '',
|
|
cloud_debt_kwh: '0.0',
|
|
night_takeover_prev: 'off',
|
|
prev_surplus_state: 'idle_wait_conditions',
|
|
grid_state: 'off',
|
|
victron_acin1_available: '0',
|
|
evcs_charging_switch: 'off',
|
|
control_latch: 'off',
|
|
charge_permission_now: 'off',
|
|
controller_enabled: 'on',
|
|
vebus_overload: 'off',
|
|
vebus_l1_overload: 'off',
|
|
vebus_low_battery: 'off',
|
|
vebus_l1_low_battery: 'off',
|
|
};
|
|
}
|
|
|
|
function summarize(out) {
|
|
const c = out.computed || {};
|
|
const d = out.decision || {};
|
|
const calls = out.ha_service_calls || {};
|
|
return {
|
|
profile: c.profile,
|
|
sourceMode: c.source_mode,
|
|
state: d.state,
|
|
decision: d.current_decision,
|
|
desiredCharging: d.desired_charging,
|
|
currentA: d.desired_current_a,
|
|
takeoverNeeded: !!c.night_grid_takeover_needed,
|
|
edfOnAction: !!calls.edf_general_ok_on?.execute,
|
|
takeoverHelperService: calls.helper_night_grid_takeover_needed?.service || null,
|
|
// Session reseau unique (03/09/2026) : null = aucune ecriture ce cycle.
|
|
sessionCommittedService: calls.helper_night_grid_session_committed?.execute
|
|
? calls.helper_night_grid_session_committed.service
|
|
: null,
|
|
// Consigne de puissance reseau ecrite ce cycle (null = aucune ecriture).
|
|
gridSetpointW: calls.grid_power_setpoint?.execute
|
|
? calls.grid_power_setpoint.data.value
|
|
: null,
|
|
};
|
|
}
|
|
|
|
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, ...baseOverrides(fixedMs), ...sc.overrides };
|
|
let payload = { data: JSON.stringify(raw) };
|
|
try {
|
|
for (const nodeName of CHAIN) payload = runNode(nodeCode[nodeName], payload, fixedMs);
|
|
} catch (e) {
|
|
failures++;
|
|
console.log(`💥 ${sc.id} : CRASH dans la chaîne — ${e.message}`);
|
|
continue;
|
|
}
|
|
const got = summarize(payload);
|
|
|
|
if (JSON_OUT) {
|
|
jsonResults[sc.id] = got;
|
|
continue;
|
|
}
|
|
|
|
if (PRINT) {
|
|
console.log(`\n─── ${sc.id} | ${sc.label}`);
|
|
console.log(' expect:', JSON.stringify(got));
|
|
if (payload.decision?.short_reason) console.log(' raison:', payload.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 (payload.decision?.short_reason) console.log(` raison: ${payload.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 OK — ${ran}/${ran} scénarios conformes`);
|
|
process.exit(failures ? 1 : 0);
|
|
}
|