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;
}
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 = {
// Le soleil doit être assez haut pour qu'une mesure de production ait un sens.

View File

@@ -1,5 +1,15 @@
{{ {
"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"),

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;
}
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 = {
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 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.
const envelope = Array.isArray(input.envelope_kwh) && input.envelope_kwh.length === 24
? input.envelope_kwh.map(v => Math.max(0, num(v, 0)))
: null;
function read24(arrayKey, csvKey) {
const direct = input[arrayKey];
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
? input.censorship_rate.map(v => Math.max(0, Math.min(1, num(v, 1))))
const envelopeRaw = read24('envelope_kwh', 'envelope_csv');
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);
const hourOfDay = (ms) =>