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:
91
src/energy-balance/BUILD_HA_CALLS.js
Normal file
91
src/energy-balance/BUILD_HA_CALLS.js
Normal 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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
53
src/energy-balance/PREPARE_BALANCE.js
Normal file
53
src/energy-balance/PREPARE_BALANCE.js
Normal 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,
|
||||
},
|
||||
}];
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user