`bucketHourly` garde la derniere valeur de chaque heure : le delta entre H-1 et H est l'energie depensee PENDANT H. `deltaSeries` le rangeait sous H-1. Discret sur un compteur dense, catastrophique sur un compteur epars — et les compteurs de cette installation le sont : `ac_in_1_to_ac_out` ne peuple que 57 heures sur 267, `evcs_total` 57 sur 264, parce qu'ils n'emettent que lorsqu'ils avancent. Une heure vide vaut « zero energie », donc toute l'energie d'un intervalle a trou a ete consommee pendant l'heure FINALE. Rangee sous l'heure initiale, l'import reseau de la nuit suivante atterrissait sur la derniere heure de la nuit precedente. Mesure du 25/08 : consumption_p50[8] = 2,90 kWh/h pour 0,80 reellement — six echantillons sur douze portaient un delta etale sur 17 heures. Le bilan reclamait environ deux fois trop d'energie le matin (3,37 kWh annonces a 06 h contre 1,35 mesures), et donc un SOC requis de 44,5 % la ou 31 % suffisait. L'enveloppe de production etait decalee de la meme heure, ce qui a contribue a surestimer la part matinale de la production dans le journal de chantier. Ajoute aussi un statut `degrade` : le bilan publie ses chiffres quand aucun croisement n'apparait sur l'horizon ou quand les heures de deficit reposent sur une forme minoree, mais il ne les presente plus comme fiables. La nuit du 25/08, avant la mise en service de la courbe horaire, il a publie un SOC requis de 100 % avec un statut `ok`. Tests 36 -> 37, dont `compteur-epars-attribution-horaire` (rafale reseau de 1/2/3 kWh a 01/02/03 h : montants distincts pour qu'un decalage d'un cran soit impossible a confondre). Mutation verifiee : l'ancienne attribution le fait echouer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
162 lines
6.2 KiB
JavaScript
162 lines
6.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, 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,
|
|
}),
|
|
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`);
|