Files
EVCS/tests/energy-balance/run.mjs
Tallagrand Thierry 8ad34f8c93 Bilan : une heure de nuit n'a aucune forme a minorer
Le drapeau « degrade » introduit la veille s'allumait de 20 h a 07 h toutes les
nuits. La qualite de forme d'une heure vient du taux de censure ; les heures
sans soleil portent un taux de 1 faute d'avoir quoi que ce soit a observer, pas
parce que la mesure serait douteuse. Comme les heures de deficit sont justement
les heures sans soleil, le drapeau se declenchait exactement quand le bilan
sert.

Une heure dont l'enveloppe demontree est nulle recoit desormais la qualite
'nuit'. Scenario epingle sur l'enveloppe et le taux de censure releves en
production, pas sur le fixture — dont la nuit valait 0 et qui ne reproduisait
donc pas le defaut. Preuve par mutation : avant 'minoree', apres 'nuit'.

Suite 37 -> 38. Documente la nuit du 25-26/08 : profil corrige verifie a
0,2 kWh/h pres, bilan qui predit le point bas a un point pres, et non-linearite
du SOC mesuree (huit points en dix-sept minutes pour 0,16 kWh).
2026-08-26 12:14:35 +02:00

167 lines
6.5 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,
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,
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'),
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}`);
}
}
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`);