BALANCE.js ne pilote rien : il publie les faits qui remplacent les seuils fixes — heure de croisement, energie a couvrir, SOC requis (remplacant de grid_soc_off), energie reseau strictement necessaire, surplus disponible pour le vehicule, indice de confiance. Sans une de ses entrees il se declare inutilisable et les controleurs gardent leurs regles. Deux niveaux de prudence. Le premier jet n'utilisait que le profil p80 et exigeait 48 % de SOC pour franchir la matinee du 24/08, soit plus cher que le seuil fixe de 30 % qu'il remplace. Corrige : la decision reseau, reevaluee a chaque cycle et reversible, utilise le profil typique p50 ; le prudent p80 sert a la planification. La marge de securite de 10 % ajoutee au depart etait une constante arbitraire et un double comptage : mesure faite, p80(somme) egale somme(p80) sur toutes les fenetres, les heures de consommation etant fortement correlees. Marge supprimee. Rejeu fige de la matinee du 24/08 (SOC 21 %, capacite 15 kWh, production reellement mesuree) : croisement a 08 h, 0,82 kWh a couvrir contre 0,80 calcule a la main, SOC requis 27,5 %, une session reseau de 0,97 kWh. Suite a 21 scenarios. MANUEL_COMPLET section 0 : description materielle complete relevee dans VRM et completee par le proprietaire — 2 MultiPlus-II 48/5000 en parallele, 53 panneaux sur trois orientations (10 est/sud-est, 30 sud, 10 ouest, 3 sur un petit MPPT), parc Pylontech, borne 32 A. Explique pourquoi aucun modele solaire mono-plan ne peut representer la production, et pourquoi Forecast.Solar mal configure se trompe de repartition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
109 lines
3.6 KiB
JavaScript
109 lines
3.6 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';
|
|
|
|
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'));
|
|
}
|
|
const sandbox = { $input: { first: () => ({ json: snapshot }) }, console };
|
|
vm.createContext(sandbox);
|
|
return vm.runInContext(`(function(){ ${codeCache.get(nodeName)} })()`, sandbox, { timeout: 15000 })[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('|'),
|
|
}),
|
|
CONSUMPTION_PROFILE: 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,
|
|
}) : ({ usable: false, missing: o.missing.join(',') }),
|
|
};
|
|
|
|
const all = [
|
|
...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })),
|
|
...profileScenarios,
|
|
...resourceScenarios,
|
|
...balanceScenarios,
|
|
];
|
|
|
|
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`);
|