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

@@ -0,0 +1,91 @@
// EVCS Energy Balance — BUILD_HA_CALLS
// =====================================================================
// GARANTIE MODE OMBRE : ce nœud ne produit QUE des écritures vers des entités
// dont le nom commence par `evcs_balance_`, plus une trace logbook. Aucun
// switch, aucune consigne de borne, aucune stratégie, aucun helper de takeover.
// Le bilan observe et publie ; il ne pilote rien tant que le câblage réel n'a
// pas été décidé explicitement.
//
// Cette garantie est VÉRIFIÉE par tests/energy-balance/ : la suite échoue si
// une écriture sort de ce périmètre. Ne pas ajouter d'autres entités ici sans
// changer le test en connaissance de cause.
// =====================================================================
const balance = $input.first().json;
const censorship = $('CENSORSHIP').first().json;
const rawSnapshot = $('HA Snapshot Balance').first().json;
const snapshot = (rawSnapshot && typeof rawSnapshot.data === 'string')
? (() => { try { return JSON.parse(rawSnapshot.data); } catch { return rawSnapshot; } })()
: ((rawSnapshot && rawSnapshot.data) || rawSnapshot);
function stampParis() {
const p = new Intl.DateTimeFormat('fr-FR', {
timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(new Date());
const g = t => p.find(x => x.type === t)?.value || '';
return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;
}
const calls = [];
function push(domain, service, data) {
calls.push({ json: { domain, service, data } });
}
function setText(entity, value) {
push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });
}
function setNumber(entity, value) {
if (!Number.isFinite(value)) return;
push('input_number', 'set_value', { entity_id: entity, value });
}
function setBool(entity, on) {
push('input_boolean', on ? 'turn_on' : 'turn_off', { entity_id: entity });
}
// Kill-switch : à OFF, on ne touche à rien du tout, pas même au statut.
const enabled = (snapshot.balance_enabled ?? 'on').toString().toLowerCase() === 'on';
if (!enabled) return [];
// --- Ce que le bilan constate de la production, dans tous les cas ---------
setBool('input_boolean.evcs_balance_pv_censored', !!censorship.censorship.pvCensored);
setText('input_text.evcs_balance_censor_reason',
censorship.censorship.pvCensored
? censorship.censorship.reasons.join(', ')
: 'production non bridée');
setText('input_text.evcs_balance_last_run', stampParis());
// --- Le bilan lui-même ---------------------------------------------------
if (!balance.usable) {
setText('input_text.evcs_balance_status', 'indisponible');
setText('input_text.evcs_balance_reason',
`Entrées manquantes : ${(balance.missing || []).join(', ')}. Les contrôleurs gardent leurs règles.`);
return calls;
}
const c = balance.coverage;
setText('input_text.evcs_balance_status', 'ok');
setNumber('input_number.evcs_balance_required_soc', c.requiredSocPct);
setNumber('input_number.evcs_balance_required_soc_prudent', c.prudent.requiredSocPct);
setNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh);
setNumber('input_number.evcs_balance_grid_energy_needed_kwh', c.gridEnergyNeededKwh);
setNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surplusAvailableKwh);
setBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded);
setText('input_text.evcs_balance_crossing_hour',
balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\'horizon');
const reason = balance.crossing.found
? `Croisement à ${balance.crossing.hour} h, ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}% (actuel ${c.currentSocPct}%). ${c.gridNeeded ? `Réseau : ${c.gridEnergyNeededKwh} kWh.` : 'Réseau non nécessaire.'}`
: `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`;
setText('input_text.evcs_balance_reason', reason);
// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil
// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.
push('logbook', 'log', {
name: 'EVCS Bilan énergétique (ombre)',
message: `${reason} | seuil fixe actuel grid_soc_off = ${snapshot.grid_soc_off ?? 'n/a'}% | production ${censorship.censorship.pvCensored ? 'BRIDÉE (' + censorship.censorship.reasons.join(', ') + ')' : 'libre'}`,
entity_id: 'input_text.evcs_balance_status',
});
return calls;

View File

@@ -34,7 +34,16 @@ function round1(v) {
return Number.isFinite(v) ? Math.round(v * 10) / 10 : null; return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;
} }
const input = $input.first().json; // Le nœud HTTP de n8n renvoie le corps du template dans `data`, sous forme de
// chaîne. Hors n8n (tests), l'objet arrive déjà décodé — on accepte les deux.
function unwrapSnapshot(raw) {
if (raw && typeof raw.data === 'string') {
try { return JSON.parse(raw.data); } catch { return raw; }
}
return (raw && raw.data) || raw;
}
const input = unwrapSnapshot($input.first().json);
const CFG = { const CFG = {
// Le soleil doit être assez haut pour qu'une mesure de production ait un sens. // Le soleil doit être assez haut pour qu'une mesure de production ait un sens.

View File

@@ -1,5 +1,15 @@
{{ { {{ {
"ts": now().isoformat(), "ts": now().isoformat(),
"nowMs": as_timestamp(now()) * 1000,
"balance_enabled": states("input_boolean.evcs_balance_enabled"),
"usable_capacity_kwh": states("input_number.evcs_balance_usable_capacity_kwh"),
"consumption_p50_csv": states("input_text.evcs_balance_consumption_p50"),
"consumption_p80_csv": states("input_text.evcs_balance_consumption_p80"),
"envelope_csv": states("input_text.evcs_balance_envelope"),
"censorship_rate_csv": states("input_text.evcs_balance_censorship_rate"),
"grid_soc_off": states("input_number.victron_grid_soc_off"),
"pv_power_w": states("sensor.victron_pv_power_system"), "pv_power_w": states("sensor.victron_pv_power_system"),

View File

@@ -0,0 +1,53 @@
// EVCS Energy Balance — PREPARE_BALANCE
// =====================================================================
// Colle de câblage n8n, et rien d'autre. Les nœuds de calcul (CENSORSHIP,
// RESOURCE_ESTIMATE, BALANCE) sont volontairement écrits pour ne lire que
// `$input`, afin de rester testables hors de n8n. Ce nœud rassemble donc ce
// que BALANCE attend, en puisant dans les nœuds précédents.
//
// Il fait aussi la seule conversion nécessaire : les profils horaires vivent
// dans des helpers `input_text` sous forme de CSV compact (24 valeurs, moins
// de 255 caractères), il faut les redonner sous forme de tableaux.
// =====================================================================
function parseCsv24(raw) {
if (typeof raw !== 'string') return null;
const parts = raw.split(',').map(v => Number.parseFloat(v.trim()));
if (parts.length !== 24 || parts.some(v => !Number.isFinite(v))) return null;
return parts;
}
// Le nœud HTTP de n8n renvoie le corps du template dans `data`, sous forme de
// chaîne. Hors n8n (tests), l'objet arrive déjà décodé — on accepte les deux.
function unwrapSnapshot(raw) {
if (raw && typeof raw.data === 'string') {
try { return JSON.parse(raw.data); } catch { return raw; }
}
return (raw && raw.data) || raw;
}
const snapshot = unwrapSnapshot($('HA Snapshot Balance').first().json);
const resource = $input.first().json;
return [{
json: {
nowMs: Number.parseFloat(snapshot.nowMs),
consumption_p50: parseCsv24(snapshot.consumption_p50_csv),
consumption_p80: parseCsv24(snapshot.consumption_p80_csv),
horizon: resource.horizon || [],
soc_percent: snapshot.soc_percent,
usable_capacity_kwh: snapshot.usable_capacity_kwh,
// Plancher dur : même valeur de repli que INPUT_PARSE.js
// (evcs_night_battery_hard_floor_pct n'est aujourd'hui exposé par AUCUN
// helper — la valeur « 22 % » documentée partout est en réalité un défaut
// de code, cf. backlog du chantier).
soc_floor_percent: 22,
// Repris pour l'écriture des helpers, sans influencer le calcul.
resourceUsable: resource.usable,
shapeConfidence: resource.source ? resource.source.shapeConfidence : null,
},
}];

View File

@@ -41,7 +41,16 @@ function round2(v) {
return Number.isFinite(v) ? Math.round(v * 100) / 100 : null; return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;
} }
const input = $input.first().json; // Le nœud HTTP de n8n renvoie le corps du template dans `data`, sous forme de
// chaîne. Hors n8n (tests), l'objet arrive déjà décodé — on accepte les deux.
function unwrapSnapshot(raw) {
if (raw && typeof raw.data === 'string') {
try { return JSON.parse(raw.data); } catch { return raw; }
}
return (raw && raw.data) || raw;
}
const input = unwrapSnapshot($input.first().json);
const CFG = { const CFG = {
TIMEZONE: 'Europe/Paris', TIMEZONE: 'Europe/Paris',
@@ -55,14 +64,26 @@ const nowMs = num(input.nowMs, Date.now());
const todayRemainingKwh = Math.max(0, num(input.forecast_today_remaining_kwh, 0)); const todayRemainingKwh = Math.max(0, num(input.forecast_today_remaining_kwh, 0));
const tomorrowKwh = Math.max(0, num(input.forecast_tomorrow_kwh, 0)); const tomorrowKwh = Math.max(0, num(input.forecast_tomorrow_kwh, 0));
// Enveloppe et taux de censure par heure : produits par le calcul quotidien. // Enveloppe et taux de censure par heure : produits par le calcul quotidien et
// stockés dans des helpers input_text sous forme de CSV compact. On accepte
// aussi le tableau déjà décodé, ce qui rend le nœud testable hors n8n.
// Absents au premier démarrage → on le dit, on ne fabrique pas de forme. // Absents au premier démarrage → on le dit, on ne fabrique pas de forme.
const envelope = Array.isArray(input.envelope_kwh) && input.envelope_kwh.length === 24 function read24(arrayKey, csvKey) {
? input.envelope_kwh.map(v => Math.max(0, num(v, 0))) const direct = input[arrayKey];
: null; if (Array.isArray(direct) && direct.length === 24) return direct.map(v => num(v, NaN));
const csv = input[csvKey];
if (typeof csv !== 'string') return null;
const parts = csv.split(',').map(v => Number.parseFloat(v.trim()));
if (parts.length !== 24 || parts.some(v => !Number.isFinite(v))) return null;
return parts;
}
const censorshipRate = Array.isArray(input.censorship_rate) && input.censorship_rate.length === 24 const envelopeRaw = read24('envelope_kwh', 'envelope_csv');
? input.censorship_rate.map(v => Math.max(0, Math.min(1, num(v, 1)))) const envelope = envelopeRaw ? envelopeRaw.map(v => Math.max(0, num(v, 0))) : null;
const censorshipRaw = read24('censorship_rate', 'censorship_rate_csv');
const censorshipRate = censorshipRaw
? censorshipRaw.map(v => Math.max(0, Math.min(1, num(v, 1))))
: Array.from({ length: 24 }, () => 1); : Array.from({ length: 24 }, () => 1);
const hourOfDay = (ms) => const hourOfDay = (ms) =>

View File

@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url';
import { scenarios as censorshipScenarios } from './scenarios.mjs'; import { scenarios as censorshipScenarios } from './scenarios.mjs';
import { profileScenarios, resourceScenarios } from './scenarios-profile.mjs'; import { profileScenarios, resourceScenarios } from './scenarios-profile.mjs';
import { balanceScenarios } from './scenarios-balance.mjs'; import { balanceScenarios } from './scenarios-balance.mjs';
import { shadowScenarios } from './scenarios-shadow.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
@@ -23,9 +24,19 @@ function runNode(nodeName, snapshot) {
if (!codeCache.has(nodeName)) { if (!codeCache.has(nodeName)) {
codeCache.set(nodeName, fs.readFileSync(path.join(ROOT, `src/energy-balance/${nodeName}.js`), 'utf8')); 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); 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 = { const SUMMARIZE = {
@@ -64,11 +75,26 @@ const SUMMARIZE = {
}) : ({ usable: false, missing: o.missing.join(',') }), }) : ({ 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 = [ const all = [
...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })), ...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })),
...profileScenarios, ...profileScenarios,
...resourceScenarios, ...resourceScenarios,
...balanceScenarios, ...balanceScenarios,
...shadowScenarios,
]; ];
let failed = 0; 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: '',
},
},
];

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env node
// Génère le squelette du workflow "EVCS Energy Balance V1" (structure, types,
// credentials, positions). Le CODE des nodes est ensuite injecté depuis
// src/energy-balance/ par tools/n8n-build.mjs — src/ reste la source de vérité.
// Rejouable : réécrit le squelette à l'identique.
import fs from 'node:fs';
const HA_CRED = { id: 'uv3sknYYigxBkaVN', name: 'Bearer Auth account 2' };
const HA_URL = 'http://10.10.10.17:8123';
const nodes = [
{
name: 'Schedule Trigger',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1.2,
position: [0, 0],
parameters: { rule: { interval: [{ field: 'minutes', minutesInterval: 5 }] } },
},
{
name: 'HA Snapshot Balance',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [220, 0],
parameters: {
method: 'POST',
url: `${HA_URL}/api/template`,
authentication: 'genericCredentialType',
genericAuthType: 'httpBearerAuth',
sendHeaders: true,
headerParameters: {
parameters: [
{ name: 'Content-Type', value: 'application/json' },
{ name: 'Accept', value: 'text/plain' },
],
},
sendBody: true,
bodyParameters: { parameters: [{ name: 'template', value: '(injecté depuis src/energy-balance)' }] },
options: { timeout: 15000 },
},
credentials: { httpBearerAuth: HA_CRED },
},
{
name: 'CENSORSHIP',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [440, -120],
parameters: { jsCode: '// injecté depuis src/energy-balance/CENSORSHIP.js' },
},
{
name: 'RESOURCE_ESTIMATE',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [440, 120],
parameters: { jsCode: '// injecté depuis src/energy-balance/RESOURCE_ESTIMATE.js' },
},
{
name: 'PREPARE_BALANCE',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [660, 120],
parameters: { jsCode: '// injecté depuis src/energy-balance/PREPARE_BALANCE.js' },
},
{
name: 'BALANCE',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [880, 120],
parameters: { jsCode: '// injecté depuis src/energy-balance/BALANCE.js' },
},
{
name: 'BUILD_HA_CALLS',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [1100, 120],
parameters: { jsCode: '// injecté depuis src/energy-balance/BUILD_HA_CALLS.js' },
},
{
name: 'Execute HA Service Balance',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [1320, 120],
parameters: {
method: 'POST',
url: `={{ '${HA_URL}/api/services/' + $json.domain + '/' + $json.service }}`,
authentication: 'genericCredentialType',
genericAuthType: 'httpBearerAuth',
sendHeaders: true,
headerParameters: {
parameters: [
{ name: 'Content-Type', value: 'application/json' },
{ name: 'accept', value: 'application/json' },
],
},
sendBody: true,
specifyBody: 'json',
jsonBody: '={{ $json.data }}',
options: { timeout: 15000 },
},
credentials: { httpBearerAuth: HA_CRED },
onError: 'continueRegularOutput',
},
];
// Le snapshot alimente DEUX branches : CENSORSHIP qualifie l'instant présent,
// RESOURCE_ESTIMATE projette la production. PREPARE_BALANCE les réunit.
const connections = {
'Schedule Trigger': { main: [[{ node: 'HA Snapshot Balance', type: 'main', index: 0 }]] },
'HA Snapshot Balance': {
main: [[
{ node: 'CENSORSHIP', type: 'main', index: 0 },
{ node: 'RESOURCE_ESTIMATE', type: 'main', index: 0 },
]],
},
RESOURCE_ESTIMATE: { main: [[{ node: 'PREPARE_BALANCE', type: 'main', index: 0 }]] },
PREPARE_BALANCE: { main: [[{ node: 'BALANCE', type: 'main', index: 0 }]] },
BALANCE: { main: [[{ node: 'BUILD_HA_CALLS', type: 'main', index: 0 }]] },
BUILD_HA_CALLS: { main: [[{ node: 'Execute HA Service Balance', type: 'main', index: 0 }]] },
};
const workflow = {
id: 'QoSfPeyGq35orF1K',
name: 'EVCS Energy Balance V1',
nodes,
connections,
settings: { executionOrder: 'v1', callerPolicy: 'workflowsFromSameOwner' },
staticData: null,
};
fs.writeFileSync('workflows/evcs-energy-balance-v1.json', JSON.stringify(workflow, null, 2) + '\n');
console.log(`Squelette écrit : workflows/evcs-energy-balance-v1.json (${nodes.length} nodes)`);
console.log('→ node tools/n8n-build.mjs workflows/evcs-energy-balance-v1.json');

View File

@@ -28,6 +28,19 @@ export const WORKFLOWS = [
{ file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Advisor', kind: 'template' }, { file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Advisor', kind: 'template' },
], ],
}, },
{
id: 'energy-balance',
workflowFile: 'workflows/evcs-energy-balance-v1.json',
srcDir: 'src/energy-balance',
nodes: [
{ file: 'CENSORSHIP.js', node: 'CENSORSHIP', kind: 'jsCode' },
{ file: 'RESOURCE_ESTIMATE.js', node: 'RESOURCE_ESTIMATE', kind: 'jsCode' },
{ file: 'PREPARE_BALANCE.js', node: 'PREPARE_BALANCE', kind: 'jsCode' },
{ file: 'BALANCE.js', node: 'BALANCE', kind: 'jsCode' },
{ file: 'BUILD_HA_CALLS.js', node: 'BUILD_HA_CALLS', kind: 'jsCode' },
{ file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Balance', kind: 'template' },
],
},
]; ];
// Rétro-compatibilité (outils historiques mono-workflow) // Rétro-compatibilité (outils historiques mono-workflow)

View File

@@ -109,11 +109,13 @@ console.log('Syntaxe OK sur tous les nodes jsCode.');
const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json'; const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json';
const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json'; const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json';
const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json'; const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json';
const isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-v1.json';
const skipGolden = process.argv.includes('--skip-golden'); const skipGolden = process.argv.includes('--skip-golden');
const testSuite = const testSuite =
isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' : isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' :
isAdvisorWorkflow && fs.existsSync('tests/advisor/run.mjs') ? 'tests/advisor/run.mjs' : isAdvisorWorkflow && fs.existsSync('tests/advisor/run.mjs') ? 'tests/advisor/run.mjs' :
isHysteresisWorkflow && fs.existsSync('tests/golden-hysteresis/run.mjs') ? 'tests/golden-hysteresis/run.mjs' : isHysteresisWorkflow && fs.existsSync('tests/golden-hysteresis/run.mjs') ? 'tests/golden-hysteresis/run.mjs' :
isBalanceWorkflow && fs.existsSync('tests/energy-balance/run.mjs') ? 'tests/energy-balance/run.mjs' :
null; null;
if (testSuite) { if (testSuite) {
if (skipGolden) { if (skipGolden) {

File diff suppressed because one or more lines are too long