Chantier etape 5 : bilan energetique publie en mode ombre

Workflow n8n "EVCS Energy Balance V1" (id QoSfPeyGq35orF1K), actif, cycle de
5 min : snapshot HA -> CENSORSHIP et RESOURCE_ESTIMATE en parallele ->
PREPARE_BALANCE -> BALANCE -> BUILD_HA_CALLS -> appels de service HA.

MODE OMBRE garanti structurellement : BUILD_HA_CALLS n'ecrit que vers des
entites evcs_balance_* plus une trace logbook. La suite de tests echoue si une
ecriture sort de ce perimetre — verifie par mutation, l'injection d'un
turn_on sur switch.edf_general_ok_switch fait rougir la suite. Kill-switch
input_boolean.evcs_balance_enabled : a OFF, aucune ecriture du tout.

Package HA evcs_energy_balance.yaml : 18 helpers. Profils de consommation,
enveloppe de production et taux de censure amorces avec les valeurs calculees
sur 28 jours reels ; capacite utile posee a 15 kWh (7 modules actifs).

Outillage : gen-energy-balance-workflow.mjs, enregistrement dans
lib-node-map.mjs, suite cablee dans n8n-push.mjs (bloquante). Suite a 24
scenarios.

Premiere execution reelle : le bilan s'est declare indisponible faute
d'entrees, exactement comme prevu, au lieu d'inventer des valeurs. Le format
du snapshot (corps JSON dans `data`) et la lecture des profils CSV ont ete
corriges ensuite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
Tallagrand Thierry
2026-08-24 15:43:37 +02:00
parent 9b24b5e29c
commit e36f050a42
11 changed files with 698 additions and 10 deletions

View File

@@ -12,6 +12,7 @@ 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 } from './scenarios-shadow.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
@@ -23,9 +24,19 @@ 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 };
// 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);
return vm.runInContext(`(function(){ ${codeCache.get(nodeName)} })()`, sandbox, { timeout: 15000 })[0].json;
const out = vm.runInContext(`(function(){ ${codeCache.get(nodeName)} })()`, sandbox, { timeout: 15000 });
return nodeName === 'BUILD_HA_CALLS' ? out : out[0].json;
}
const SUMMARIZE = {
@@ -64,11 +75,26 @@ const SUMMARIZE = {
}) : ({ 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'),
};
};
const all = [
...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })),
...profileScenarios,
...resourceScenarios,
...balanceScenarios,
...shadowScenarios,
];
let failed = 0;

View File

@@ -0,0 +1,81 @@
// Scénarios de BUILD_HA_CALLS — garantie du mode ombre.
// La suite doit ÉCHOUER si une écriture sort du périmètre `evcs_balance_*`.
// C'est la seule protection structurelle contre un câblage réel accidentel.
const SNAPSHOT = {
balance_enabled: 'on',
grid_soc_off: '30.0',
soc_percent: '21',
usable_capacity_kwh: '15',
};
const CENSORSHIP_CENSURE = {
censorship: { pvCensored: true, reasons: ['batterie_pleine_sans_puits'] },
};
const CENSORSHIP_LIBRE = {
censorship: { pvCensored: false, reasons: [] },
};
const BALANCE_OK = {
usable: true,
crossing: { found: true, hour: 8 },
coverage: {
energyToCoverKwh: 0.82,
requiredSocPct: 27.5,
currentSocPct: 21,
socMarginPct: -6.5,
gridNeeded: true,
gridEnergyNeededKwh: 0.97,
prudent: { requiredSocPct: 38.1 },
},
vehicle: { surplusAvailableKwh: 20.17 },
};
export const shadowScenarios = [
{
node: 'BUILD_HA_CALLS',
id: 'ombre-perimetre-respecte',
label: 'Bilan complet : toutes les écritures restent dans evcs_balance_*',
snapshot: {
...BALANCE_OK,
__nodes: { 'HA Snapshot Balance': SNAPSHOT, CENSORSHIP: CENSORSHIP_CENSURE },
},
expect: {
outOfScope: '',
wroteRequiredSoc: true,
wroteGridNeeded: true,
hasLogbook: true,
},
},
{
node: 'BUILD_HA_CALLS',
id: 'ombre-bilan-indisponible',
label: 'Bilan inutilisable : statut publié, aucune valeur inventée',
snapshot: {
usable: false,
missing: ['usable_capacity_kwh'],
__nodes: { 'HA Snapshot Balance': SNAPSHOT, CENSORSHIP: CENSORSHIP_LIBRE },
},
expect: {
outOfScope: '',
wroteRequiredSoc: false,
hasLogbook: false,
},
},
{
node: 'BUILD_HA_CALLS',
id: 'ombre-kill-switch',
label: 'Kill-switch à OFF : absolument aucune écriture',
snapshot: {
...BALANCE_OK,
__nodes: {
'HA Snapshot Balance': { ...SNAPSHOT, balance_enabled: 'off' },
CENSORSHIP: CENSORSHIP_CENSURE,
},
},
expect: {
callCount: 0,
outOfScope: '',
},
},
];