Files
EVCS/tests/energy-balance/run.mjs

239 lines
11 KiB
JavaScript

#!/usr/bin/env node
// Suite de tests du chantier bilan énergétique.
// Exécute le VRAI code des nœuds de src/energy-balance/ dans une sandbox vm,
// avec le shim $input de n8n, sur des snapshots HA (réels + cas construits).
//
// Usage : node tests/energy-balance/run.mjs [--print] [--only=<motif>]
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
import { scenarios as censorshipScenarios } from './scenarios.mjs';
import { profileScenarios, resourceScenarios } from './scenarios-profile.mjs';
import { balanceScenarios } from './scenarios-balance.mjs';
import { shadowScenarios, profilesShadowScenarios, forecastScenarios } from './scenarios-shadow.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const PRINT = process.argv.includes('--print');
const ONLY = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1];
const codeCache = new Map();
function runNode(nodeName, snapshot) {
if (!codeCache.has(nodeName)) {
codeCache.set(nodeName, fs.readFileSync(path.join(ROOT, `src/energy-balance/${nodeName}.js`), 'utf8'));
}
// Certains nœuds sont de la colle n8n et lisent d'autres nœuds via $('Nom').
const upstream = snapshot.__nodes || {};
const sandbox = {
$input: { first: () => ({ json: snapshot }) },
$: (name) => {
if (!(name in upstream)) throw new Error(`nœud amont non fourni au test : ${name}`);
return { first: () => ({ json: upstream[name] }) };
},
console,
};
vm.createContext(sandbox);
const out = vm.runInContext(`(function(){ ${codeCache.get(nodeName)} })()`, sandbox, { timeout: 15000 });
return nodeName.startsWith('BUILD_HA_CALLS') ? out : out[0].json;
}
const SUMMARIZE = {
CENSORSHIP: o => ({
pvCensored: o.censorship.pvCensored,
reasons: o.censorship.reasons.join('|'),
sampleExploitable: o.censorship.sampleExploitable,
healthOk: o.health.ok,
alerts: o.alerts.join('|'),
}),
DAILY_PROFILES: o => ({
days: o.days,
complete: o.complete,
hours: o.profile.length,
countersReset: o.quality.countersReset,
unreliableHours: o.quality.unreliableHours.join(','),
csvFitsHelper: o.csv.p80.length <= 255 && o.csv.p50.length <= 255,
p50AtHour14: o.profile[14].p50,
p80AtHour14: o.profile[14].p80,
// Heures 1 à 3 et 8 : sert au scénario d'attribution horaire, où un
// compteur épars ne se réveille que la nuit.
p50Hours1a3: o.profile.slice(1, 4).map(h => h.p50).join(','),
p50AtHour8: o.profile[8].p50,
spannedGaps: o.quality.spannedGaps,
}),
RESOURCE_ESTIMATE: o => ({
usable: o.usable,
mode: o.mode,
// Quelle courbe a ete retenue, et pour quel jour (correctif du 08/09).
alignment: o.source.forecastAlignment,
todayExpected: o.todayExpectedRemainingKwh,
tomorrowExpected: o.tomorrowExpectedKwh,
shapeConfidenceBelow: o.source.shapeConfidence != null && o.source.shapeConfidence < 0.5,
// Qualite de forme d'une heure de nuit (03 h) et d'une heure de plein
// apres-midi (14 h) : sert au scenario de nuit, ou une heure sans soleil
// ne doit pas etre declaree « minoree ».
qualityAtHour3: (o.horizon.find(h => h.hour === 3) || {}).shapeQuality,
qualityAtHour14: (o.horizon.find(h => h.hour === 14) || {}).shapeQuality,
}),
BALANCE: o => o.usable ? ({
usable: true,
crossingFound: o.crossing.found,
crossingHour: o.crossing.hour,
energyToCoverKwh: o.coverage.energyToCoverKwh,
requiredSocPct: o.coverage.requiredSocPct,
gridNeeded: o.coverage.gridNeeded,
// Le MANQUE, et pas l'energie a couvrir. C'est lui qui finance la session :
// depuis le 18/09 la consigne reseau dimensionne sa part batterie dessus.
// Il manquait au resume alors qu'il est desormais consomme en aval.
gridEnergyNeededKwh: Math.round(o.coverage.gridEnergyNeededKwh * 100) / 100,
prudentRequiredSocPct: o.coverage.prudent.requiredSocPct,
deficitTrustworthy: o.confidence.deficitTrustworthy,
nextDeficitFound: o.nextDeficit.found,
nextDeficitStartHour: o.nextDeficit.found ? o.nextDeficit.startHour : null,
nextDeficitEnergyKwh: o.nextDeficit.found ? o.nextDeficit.energyKwh : null,
nextDeficitRequiredSocPct: o.nextDeficit.found ? o.nextDeficit.requiredSocAtStartPct : null,
storageShortfallKwh: o.nextDeficit.found ? Math.round(o.nextDeficit.storageShortfallKwh * 100) / 100 : null,
// Réserve d'orage : niveau lu, activation, et le chiffre qui compte —
// ce qu'il faut en batterie pour traverser sans réseau.
stormLevel: o.storm.level,
stormActive: o.storm.active,
stormReserveKwh: o.storm.reserveKwh,
stormReserveSocPct: o.storm.reserveSocPct,
stormCovered: o.storm.covered,
stormShortfallKwh: o.storm.shortfallKwh,
// Tenir jusqu'a la prochaine fenetre HC (28/09).
hcReachActive: o.hcReach ? o.hcReach.active : false,
hcFloorPct: o.hcReach ? o.hcReach.floorPct : null,
hcNightFloorPrudentPct: o.hcReach ? o.hcReach.nightFloorPrudentPct : null,
// Nuit prevue par la soiree (28/09).
nightModelActive: o.nightModel ? o.nightModel.active : false,
nightFactor: o.nightModel ? o.nightModel.factor : null,
// Maison sur le reseau pendant la fenetre HC de nuit (29/09).
nightHcActive: o.nightHcImport ? o.nightHcImport.active : false,
nightHcHouseKwh: o.nightHcImport ? o.nightHcImport.houseKwh : null,
// Budget voiture de la nuit sans EDF (30/09).
nightVehicleBudgetActive: o.nightVehicleBudget ? o.nightVehicleBudget.active : false,
nightVehicleBudgetKwh: o.nightVehicleBudget ? o.nightVehicleBudget.kwh : null,
// Voiture branchee en HC, depart avant la prochaine HC (28/09).
vehicleActive: o.vehicleMidday ? o.vehicleMidday.active : false,
vehicleNeedKwh: o.vehicleMidday ? o.vehicleMidday.needKwh : null,
vehicleReachableKwh: o.vehicleMidday ? o.vehicleMidday.reachableKwh : null,
vehicleSolarKwh: o.vehicleMidday ? o.vehicleMidday.solarKwh : null,
vehicleFloorAtDeparturePct: o.vehicleMidday ? o.vehicleMidday.floorAtDeparturePct : null,
vehicleGridNeeded: o.vehicleMidday ? o.vehicleMidday.gridNeeded : false,
}) : ({ usable: false, missing: o.missing.join(',') }),
};
// BUILD_HA_CALLS renvoie une LISTE d'appels, pas un objet unique.
SUMMARIZE.BUILD_HA_CALLS = (calls) => {
const entities = calls
.filter(c => c.json.domain !== 'logbook')
.map(c => c.json.data.entity_id || '');
return {
callCount: calls.length,
outOfScope: entities.filter(e => !/^(input_text|input_number|input_boolean)\.evcs_balance_/.test(e)).join(','),
wroteRequiredSoc: entities.includes('input_number.evcs_balance_required_soc'),
wroteGridNeeded: entities.includes('input_boolean.evcs_balance_grid_needed'),
// Les 5 écritures de la réserve d'orage sont-elles bien émises ET dans le
// périmètre ombre ? (outOfScope les attraperait si elles en sortaient).
wroteStorm: entities.filter(e => /evcs_balance_storm_/.test(e)).length,
hasLogbook: calls.some(c => c.json.domain === 'logbook'),
wroteProfiles: entities.includes('input_text.evcs_balance_consumption_p50'),
status: (calls.find(c => c.json.data.entity_id === 'input_text.evcs_balance_status') || { json: { data: {} } }).json.data.value,
};
};
SUMMARIZE.BUILD_HA_CALLS_PROFILES = SUMMARIZE.BUILD_HA_CALLS;
SUMMARIZE.BUILD_HA_CALLS_FORECAST = (calls) => {
const base = SUMMARIZE.BUILD_HA_CALLS(calls);
const entities = calls.filter(c => c.json.domain !== 'logbook').map(c => c.json.data.entity_id || '');
return { ...base, wroteForecast: entities.includes('input_text.evcs_balance_forecast_today') };
};
SUMMARIZE.BUILD_FORECAST_URL = (o) => ({
urlOk: o.ok,
problems: (o.problems || []).join(','),
planeCount: o.planeCount ?? null,
urlTail: o.url ? o.url.split('/period/')[1].split('/').slice(2).join('/') : null,
});
const all = [
...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })),
...profileScenarios,
...resourceScenarios,
...balanceScenarios,
...shadowScenarios,
...profilesShadowScenarios,
...forecastScenarios,
];
let failed = 0;
let run = 0;
for (const sc of all) {
if (ONLY && !sc.id.includes(ONLY)) continue;
run++;
const got = SUMMARIZE[sc.node](runNode(sc.node, sc.snapshot));
if (PRINT) {
console.log(`─── [${sc.node}] ${sc.id} | ${sc.label}`);
console.log(' ' + JSON.stringify(got));
continue;
}
const diffs = [];
for (const [k, want] of Object.entries(sc.expect)) {
if (JSON.stringify(got[k]) !== JSON.stringify(want)) {
diffs.push(` ${k}: attendu ${JSON.stringify(want)}, obtenu ${JSON.stringify(got[k])}`);
}
}
if (diffs.length) {
failed++;
console.log(`❌ ${sc.id} | ${sc.label}`);
diffs.forEach(d => console.log(d));
} else {
console.log(`✅ ${sc.id}`);
}
}
// Garde-fou (28/09) : tout champ que BALANCE.js lit sur son entree doit etre
// produit par PREPARE_BALANCE.js. Les scenarios appellent BALANCE directement :
// un champ oublie dans la colle passait tous les tests et rendait une regle
// muette en production (heures HC, nuit du 28/09).
if (!PRINT && (!ONLY || 'prepare-balance-transmet-tout'.includes(ONLY))) {
run++;
const src = fs.readFileSync(path.join(ROOT, 'src/energy-balance/BALANCE.js'), 'utf8');
const lus = new Set([...src.matchAll(/(?<![$\w])input\.([A-Za-z_0-9]+)/g)].map(m => m[1]));
const prep = fs.readFileSync(path.join(ROOT, 'src/energy-balance/PREPARE_BALANCE.js'), 'utf8');
const manquants = [...lus].filter(k => !new RegExp(`^\\s*${k}\\s*:`, 'm').test(prep));
if (manquants.length) {
failed++;
console.log(`❌ prepare-balance-transmet-tout | champs lus par BALANCE absents de PREPARE_BALANCE : ${manquants.join(', ')}`);
} else {
console.log(`✅ prepare-balance-transmet-tout (${lus.size} champs)`);
}
}
// Garde-fou (06/10) : le seuil d'assistance batterie que le bilan compte pour
// la voiture a midi doit etre CELUI du controleur. S'ils divergent, le bilan
// promet a la voiture une batterie que le controleur ne lui donnera pas.
if (!PRINT && (!ONLY || 'seuil-assistance-aligne'.includes(ONLY))) {
run++;
const bal = fs.readFileSync(path.join(ROOT, 'src/energy-balance/BALANCE.js'), 'utf8');
const ctl = fs.readFileSync(path.join(ROOT, 'src/nodes/BATTERY_CLOUD_LOCKS.js'), 'utf8');
const a = (bal.match(/const VEHICLE_BATTERY_ASSIST_SOC_PCT = (\d+(?:\.\d+)?);/) || [])[1];
const b = (ctl.match(/const batteryAssistSolarStartSocMin = (\d+(?:\.\d+)?);/) || [])[1];
if (!a || !b || Number(a) !== Number(b)) {
failed++;
console.log(`❌ seuil-assistance-aligne | bilan ${a} contre controleur ${b}`);
} else {
console.log(`✅ seuil-assistance-aligne (${a} %)`);
}
}
if (PRINT) process.exit(0);
console.log('');
if (failed) {
console.log(`${failed} ÉCHEC(S) — NE PAS POUSSER EN PRODUCTION`);
process.exit(1);
}
console.log(`ENERGY BALANCE OK — ${run}/${run} scénarios conformes`);