5c — BALANCE ne repondait qu'a "combien jusqu'a ce que le solaire prenne le relais". A 15 h la reponse est zero : exacte mais inutile, le deficit qui approche etant celui de la nuit. Ajout de la prochaine traversee deficitaire (heure de debut, energie, SOC a atteindre avant). Ce SOC n'est PAS ecrete a 100 % : au-dela, meme une batterie pleine ne passe pas, et le manque est publie comme storage_shortfall_kwh — c'est le chiffre du dimensionnement. 5b — Workflow "EVCS Energy Profiles Daily V1" (N6UaB4ZozsbRPrFb), actif, 03:30 : lit /api/history/period sur 30 jours glissants et republie les quatre CSV (conso p50 et p80, enveloppe de production, taux de censure). CONSUMPTION_PROFILE devient DAILY_PROFILES et sait desormais decoder la reponse brute de l'API en plus des series deja pretes. Piege trouve : sans end_time, /api/history/period ne renvoie qu'UNE journee, silencieusement. Mes premieres mesures "sur 3 jours" n'en couvraient qu'une. Validation croisee : profils calcules depuis l'API REST (etats bruts, 11 j) contre profils calcules depuis les statistiques long terme (28 j) — 0,20 kWh/h d'ecart absolu moyen, par deux chemins de donnees independants. BUILD_HA_CALLS_PROFILES refuse d'ecraser les profils en place si l'historique ne couvre pas les 24 heures. Meme garantie de perimetre que le bilan. Suite a 27 scenarios. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
143 lines
5.2 KiB
JavaScript
143 lines
5.2 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 } 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,
|
|
}),
|
|
RESOURCE_ESTIMATE: o => ({
|
|
usable: o.usable,
|
|
todayExpected: o.todayExpectedRemainingKwh,
|
|
tomorrowExpected: o.tomorrowExpectedKwh,
|
|
shapeConfidenceBelow: o.source.shapeConfidence != null && o.source.shapeConfidence < 0.5,
|
|
}),
|
|
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,
|
|
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,
|
|
}) : ({ 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'),
|
|
hasLogbook: calls.some(c => c.json.domain === 'logbook'),
|
|
wroteProfiles: entities.includes('input_text.evcs_balance_consumption_p50'),
|
|
};
|
|
};
|
|
SUMMARIZE.BUILD_HA_CALLS_PROFILES = SUMMARIZE.BUILD_HA_CALLS;
|
|
|
|
const all = [
|
|
...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })),
|
|
...profileScenarios,
|
|
...resourceScenarios,
|
|
...balanceScenarios,
|
|
...shadowScenarios,
|
|
...profilesShadowScenarios,
|
|
];
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
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`);
|