diff --git a/src/energy-balance/BUILD_HA_CALLS.js b/src/energy-balance/BUILD_HA_CALLS.js new file mode 100644 index 0000000..ecc198b --- /dev/null +++ b/src/energy-balance/BUILD_HA_CALLS.js @@ -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; diff --git a/src/energy-balance/CENSORSHIP.js b/src/energy-balance/CENSORSHIP.js index 5456220..6f2dbfb 100644 --- a/src/energy-balance/CENSORSHIP.js +++ b/src/energy-balance/CENSORSHIP.js @@ -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. diff --git a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl index aa52820..0191538 100644 --- a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -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"), diff --git a/src/energy-balance/PREPARE_BALANCE.js b/src/energy-balance/PREPARE_BALANCE.js new file mode 100644 index 0000000..e66cb04 --- /dev/null +++ b/src/energy-balance/PREPARE_BALANCE.js @@ -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, + }, +}]; diff --git a/src/energy-balance/RESOURCE_ESTIMATE.js b/src/energy-balance/RESOURCE_ESTIMATE.js index ef13df7..5acd58b 100644 --- a/src/energy-balance/RESOURCE_ESTIMATE.js +++ b/src/energy-balance/RESOURCE_ESTIMATE.js @@ -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) => diff --git a/tests/energy-balance/run.mjs b/tests/energy-balance/run.mjs index bdccbc8..2f7587f 100644 --- a/tests/energy-balance/run.mjs +++ b/tests/energy-balance/run.mjs @@ -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; diff --git a/tests/energy-balance/scenarios-shadow.mjs b/tests/energy-balance/scenarios-shadow.mjs new file mode 100644 index 0000000..cf15721 --- /dev/null +++ b/tests/energy-balance/scenarios-shadow.mjs @@ -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: '', + }, + }, +]; diff --git a/tools/gen-energy-balance-workflow.mjs b/tools/gen-energy-balance-workflow.mjs new file mode 100644 index 0000000..ae9a44e --- /dev/null +++ b/tools/gen-energy-balance-workflow.mjs @@ -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'); diff --git a/tools/lib-node-map.mjs b/tools/lib-node-map.mjs index c057367..01daaec 100644 --- a/tools/lib-node-map.mjs +++ b/tools/lib-node-map.mjs @@ -28,6 +28,19 @@ export const WORKFLOWS = [ { 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) diff --git a/tools/n8n-push.mjs b/tools/n8n-push.mjs index 9027956..3b02f05 100644 --- a/tools/n8n-push.mjs +++ b/tools/n8n-push.mjs @@ -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 isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-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 testSuite = isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/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' : + isBalanceWorkflow && fs.existsSync('tests/energy-balance/run.mjs') ? 'tests/energy-balance/run.mjs' : null; if (testSuite) { if (skipGolden) { diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json new file mode 100644 index 0000000..78d70c0 --- /dev/null +++ b/workflows/evcs-energy-balance-v1.json @@ -0,0 +1,250 @@ +{ + "name": "EVCS Energy Balance V1", + "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": "http://10.10.10.17:8123/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": "{{ {\n \"ts\": now().isoformat(),\n \"nowMs\": as_timestamp(now()) * 1000,\n\n \"balance_enabled\": states(\"input_boolean.evcs_balance_enabled\"),\n \"usable_capacity_kwh\": states(\"input_number.evcs_balance_usable_capacity_kwh\"),\n\n \"consumption_p50_csv\": states(\"input_text.evcs_balance_consumption_p50\"),\n \"consumption_p80_csv\": states(\"input_text.evcs_balance_consumption_p80\"),\n \"envelope_csv\": states(\"input_text.evcs_balance_envelope\"),\n \"censorship_rate_csv\": states(\"input_text.evcs_balance_censorship_rate\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"battery_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"grid_switch\": states(\"switch.edf_general_ok_switch\"),\n \"acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n \"grid_power_l1_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"multiplus_state\": states(\"sensor.airtria_mpii_v1_state\"),\n \"multiplus_overload_alarm\": states(\"sensor.airtria_mpii_v1_overload_alarm\"),\n \"system_state\": states(\"sensor.gx_device_system_state\"),\n \"ess_batterylife_state\": states(\"sensor.gx_device_ess_batterylife_state\"),\n \"dc_battery_state\": states(\"sensor.gx_device_dc_battery_state\"),\n\n \"mppt_1_state\": states(\"sensor.airtria_mppt1_state\"),\n \"mppt_2_state\": states(\"sensor.mppt_450_200_hq2449yr9pt_state\"),\n \"mppt_3_state\": states(\"sensor.v1_state\"),\n\n \"evcs_native_status\": states(\"sensor.evcs_hq2239dnt7p_status\"),\n\n \"forecast_today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"forecast_today_kwh\": states(\"sensor.energy_production_today\"),\n \"forecast_tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n \"forecast_next_hour_w\": states(\"sensor.power_production_next_hour\"),\n\n \"sun_elevation\": state_attr(\"sun.sun\", \"elevation\")\n} | tojson }}\n" + } + ] + }, + "options": { + "timeout": 15000 + } + }, + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + }, + { + "name": "CENSORSHIP", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 440, + -120 + ], + "parameters": { + "jsCode": "// EVCS Energy Balance — CENSORSHIP\n// =====================================================================\n// Rôle : dire si la production PV mesurée à cet instant représente la\n// RESSOURCE solaire disponible, ou si elle est CENSURÉE.\n//\n// L'installation est un ESS Victron sans injection réseau. Le MPPT ne produit\n// que ce que le système peut absorber immédiatement :\n//\n// PV_mesurée = min( ressource réelle , conso maison + absorption batterie )\n//\n// Dès que la batterie est pleine ou que le réseau alimente AC-IN, la mesure est\n// une observation censurée à droite : elle dit « on n'avait pas besoin de plus »,\n// pas « il n'y avait pas plus ». Sur l'historique du 28/07 au 24/08, 48 % des\n// heures de jour sont censurées — 77 à 96 % à partir de 13 h. Bâtir un profil de\n// production sur la mesure brute revient donc à mesurer sa propre consommation.\n//\n// Ce nœud ne décide RIEN. Il qualifie l'échantillon, pour que l'estimation de\n// ressource ne soit recalée que sur les heures exploitables.\n//\n// Sortie : censorship { pvCensored, reasons[], sampleExploitable, ... }\n// health { residualW, ok } — contrôle de vraisemblance du bus DC.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction round1(v) {\n return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;\n}\n\n// Le nœud HTTP de n8n renvoie le corps du template dans `data`, sous forme de\n// chaîne. Hors n8n (tests), l'objet arrive déjà décodé — on accepte les deux.\nfunction unwrapSnapshot(raw) {\n if (raw && typeof raw.data === 'string') {\n try { return JSON.parse(raw.data); } catch { return raw; }\n }\n return (raw && raw.data) || raw;\n}\n\nconst input = unwrapSnapshot($input.first().json);\n\nconst CFG = {\n // Le soleil doit être assez haut pour qu'une mesure de production ait un sens.\n SUN_ELEVATION_MIN_DEG: 10,\n // En dessous, on est dans le bruit (aube, crépuscule, couverture totale).\n PV_MEANINGFUL_MIN_W: 200,\n // SOC à partir duquel la batterie ne peut plus servir de puits significatif.\n SOC_NEAR_FULL_PCT: 90,\n // Puissance de charge batterie en dessous de laquelle, batterie quasi pleine,\n // on considère qu'il n'y a plus de preneur pour le surplus.\n BATTERY_SINK_MIN_W: 500,\n // États VE.Bus de fin de charge : le courant est volontairement réduit.\n MULTIPLUS_TAPER_STATES: ['absorption', 'float', 'storage'],\n // Tolérance du contrôle de vraisemblance PV + VE.Bus = batterie.\n HEALTH_ABS_TOLERANCE_W: 150,\n HEALTH_REL_TOLERANCE: 0.15,\n};\n\nconst pvPowerW = num(input.pv_power_w, 0);\nconst housePowerW = num(input.house_ac_power_w, 0);\nconst evcsPowerW = num(input.evcs_ac_power_w, 0);\nconst socPct = num(input.soc_percent, NaN);\nconst batteryCurrentA = num(input.battery_current_a, NaN);\nconst batteryVoltageV = num(input.battery_voltage_v, NaN);\nconst vebusDcPowerW = num(input.vebus_dc_power_w, NaN);\nconst sunElevationDeg = num(input.sun_elevation, NaN);\nconst forecastNextHourW = num(input.forecast_next_hour_w, NaN);\n\nconst batteryPowerW =\n Number.isFinite(batteryCurrentA) && Number.isFinite(batteryVoltageV)\n ? batteryCurrentA * batteryVoltageV\n : NaN;\n\nconst multiplusState = low(input.multiplus_state);\nconst systemState = low(input.system_state);\nconst essBatteryLifeState = low(input.ess_batterylife_state);\nconst dcBatteryState = low(input.dc_battery_state);\nconst overloadAlarm = low(input.multiplus_overload_alarm);\nconst evcsNativeStatus = low(input.evcs_native_status);\n\nconst mpptStates = [input.mppt_1_state, input.mppt_2_state, input.mppt_3_state]\n .map(low)\n .filter(s => s !== '' && s !== 'unavailable' && s !== 'unknown');\n\n// AC-IN physiquement présent : c'est lui qui compte, pas la position du switch\n// (le switch peut être ON alors que le disjoncteur est coupé — incident 08/07).\nconst gridPresent =\n num(input.acin1_available, 0) === 1 || low(input.grid_switch) === 'on';\n\nconst sunUp =\n !Number.isFinite(sunElevationDeg) || sunElevationDeg >= CFG.SUN_ELEVATION_MIN_DEG;\n\n// =====================================================================\n// CENSURE — chaque motif s'appuie sur un signal réellement lu, pas sur un seuil\n// supposé. Ils sont cumulables : on garde la liste complète pour le diagnostic.\n// =====================================================================\nconst reasons = [];\n\nif (gridPresent) {\n reasons.push('reseau_present');\n}\n\nif (CFG.MULTIPLUS_TAPER_STATES.includes(multiplusState)) {\n reasons.push(`fin_de_charge_${multiplusState}`);\n}\n\nconst batteryNearFull =\n Number.isFinite(socPct) && socPct >= CFG.SOC_NEAR_FULL_PCT;\nconst batterySinkWeak =\n !Number.isFinite(batteryPowerW) || batteryPowerW < CFG.BATTERY_SINK_MIN_W;\n\nif (sunUp && batteryNearFull && batterySinkWeak) {\n reasons.push('batterie_pleine_sans_puits');\n}\n\nif (sunUp && dcBatteryState === 'idle' && pvPowerW > CFG.PV_MEANINGFUL_MIN_W) {\n reasons.push('bus_dc_au_repos_en_plein_jour');\n}\n\n// Un tracker à l'arrêt en plein jour n'est pas une panne : c'est le MPPT qui\n// coupe faute de preneur. C'est le signal le plus direct d'un écrêtage.\nconst mpptOffCount = mpptStates.filter(s => s === 'off').length;\nif (sunUp && mpptOffCount > 0 && mpptStates.length > 0) {\n reasons.push(`mppt_arretes_${mpptOffCount}_sur_${mpptStates.length}`);\n}\n\nconst pvCensored = reasons.length > 0;\n\n// Un échantillon n'est exploitable pour estimer la ressource que s'il n'est pas\n// censuré ET qu'il y a assez de soleil pour que la mesure signifie quelque chose.\nconst sampleExploitable =\n !pvCensored && sunUp && pvPowerW >= CFG.PV_MEANINGFUL_MIN_W;\n\n// Indication (non un fait) de ce qu'on laisse passer : la prévision porte sur\n// l'heure suivante, pas sur l'instant présent — à ne pas cumuler comme un\n// compteur d'énergie perdue.\nconst shortfallHintW =\n pvCensored && Number.isFinite(forecastNextHourW)\n ? Math.max(0, forecastNextHourW - pvPowerW)\n : 0;\n\n// =====================================================================\n// SANTÉ DES MESURES — identité du bus DC : PV + VE.Bus = batterie.\n// Vérifiée à 95 W près le 24/08/2026. Le jour où elle diverge, un capteur ment.\n// =====================================================================\nconst healthMeasurable =\n Number.isFinite(pvPowerW) && Number.isFinite(vebusDcPowerW) && Number.isFinite(batteryPowerW);\n\nconst residualW = healthMeasurable ? pvPowerW + vebusDcPowerW - batteryPowerW : NaN;\nconst healthTolerance = Math.max(\n CFG.HEALTH_ABS_TOLERANCE_W,\n CFG.HEALTH_REL_TOLERANCE * Math.abs(batteryPowerW || 0)\n);\nconst healthOk = healthMeasurable ? Math.abs(residualW) <= healthTolerance : null;\n\n// =====================================================================\n// ALERTES SYSTÈME — jusqu'ici jamais lues par aucun workflow.\n// =====================================================================\nconst alerts = [];\nif (overloadAlarm && overloadAlarm !== 'no_alarm') alerts.push(`multiplus_overload_${overloadAlarm}`);\nif (essBatteryLifeState.includes('soc_below')) alerts.push('batterylife_limite_le_soc');\nif (healthOk === false) alerts.push('incoherence_mesures_bus_dc');\n\nreturn [{\n json: {\n ts: input.ts,\n\n measured: {\n pvPowerW: round1(pvPowerW),\n housePowerW: round1(housePowerW),\n houseWithoutEvcsW: round1(Math.max(0, housePowerW - evcsPowerW)),\n evcsPowerW: round1(evcsPowerW),\n socPct: round1(socPct),\n batteryPowerW: round1(batteryPowerW),\n vebusDcPowerW: round1(vebusDcPowerW),\n sunElevationDeg: round1(sunElevationDeg),\n forecastNextHourW: Number.isFinite(forecastNextHourW) ? forecastNextHourW : null,\n },\n\n states: {\n multiplusState,\n systemState,\n essBatteryLifeState,\n dcBatteryState,\n mpptStates,\n evcsNativeStatus,\n gridPresent,\n },\n\n censorship: {\n pvCensored,\n reasons,\n sampleExploitable,\n shortfallHintW: round1(shortfallHintW),\n sunUp,\n },\n\n health: {\n measurable: healthMeasurable,\n residualW: round1(residualW),\n toleranceW: round1(healthTolerance),\n ok: healthOk,\n },\n\n alerts,\n cfg: CFG,\n },\n}];\n" + } + }, + { + "name": "RESOURCE_ESTIMATE", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 440, + 120 + ], + "parameters": { + "jsCode": "// EVCS Energy Balance — RESOURCE_ESTIMATE\n// =====================================================================\n// Rôle : estimer la production PV attendue heure par heure, en RESSOURCE\n// (ce que l'installation peut produire) et non en mesure censurée.\n//\n// Trois sources ont été évaluées le 24/08/2026, deux ont été écartées :\n//\n// 1. Recalage de la prévision sur les heures non censurées — ÉCARTÉ.\n// La censure est corrélée au beau temps : les heures exploitables de\n// l'après-midi ne le sont que les jours médiocres. Le facteur mesuré va\n// de 4,29 à 9 h (27 échantillons) à 0,48 à 15 h (3 échantillons).\n// Données manquantes non aléatoires, aucun facteur transportable.\n//\n// 2. Forme issue de la géométrie solaire — ÉCARTÉ. Un modèle mono-plan\n// ajusté sur les heures propres converge sur « plein est, 20° » et\n// prédit alors 2,0 % de la journée à 18 h contre 5,8 % réellement\n// mesurés. L'installation a manifestement plusieurs orientations\n// (trois trackers), qu'un plan unique ne peut pas représenter.\n//\n// 3. RETENU — FORME = enveloppe horaire réellement démontrée (production\n// maximale jamais observée à chaque heure). Elle encode le comportement\n// réel du champ, orientations multiples comprises, sans aucun modèle.\n// MAGNITUDE = prévision Forecast.Solar (énergie restante du jour), qui\n// porte la météo et ignore totalement l'état de notre batterie.\n//\n// LIMITE ASSUMÉE ET SIGNALÉE : l'enveloppe de l'après-midi est elle-même\n// censurée (77 à 96 % des heures après 13 h le sont), donc sous-estimée. Les\n// heures du matin sont propres (censure de 0 à 4 %). Le champ `envelopeQuality`\n// dit, heure par heure, si la forme est fiable ou minorée — à charge du\n// consommateur d'en tenir compte plutôt que de le découvrir trop tard.\n//\n// Ce nœud ne décide rien.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\n// Le nœud HTTP de n8n renvoie le corps du template dans `data`, sous forme de\n// chaîne. Hors n8n (tests), l'objet arrive déjà décodé — on accepte les deux.\nfunction unwrapSnapshot(raw) {\n if (raw && typeof raw.data === 'string') {\n try { return JSON.parse(raw.data); } catch { return raw; }\n }\n return (raw && raw.data) || raw;\n}\n\nconst input = unwrapSnapshot($input.first().json);\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n HORIZON_HOURS: 30,\n // Au-delà de cette part d'heures censurées, la forme de l'enveloppe est\n // considérée comme minorée et signalée comme telle.\n CENSORSHIP_QUALITY_LIMIT: 0.25,\n};\n\nconst nowMs = num(input.nowMs, Date.now());\nconst todayRemainingKwh = Math.max(0, num(input.forecast_today_remaining_kwh, 0));\nconst tomorrowKwh = Math.max(0, num(input.forecast_tomorrow_kwh, 0));\n\n// Enveloppe et taux de censure par heure : produits par le calcul quotidien et\n// stockés dans des helpers input_text sous forme de CSV compact. On accepte\n// aussi le tableau déjà décodé, ce qui rend le nœud testable hors n8n.\n// Absents au premier démarrage → on le dit, on ne fabrique pas de forme.\nfunction read24(arrayKey, csvKey) {\n const direct = input[arrayKey];\n if (Array.isArray(direct) && direct.length === 24) return direct.map(v => num(v, NaN));\n const csv = input[csvKey];\n if (typeof csv !== 'string') return null;\n const parts = csv.split(',').map(v => Number.parseFloat(v.trim()));\n if (parts.length !== 24 || parts.some(v => !Number.isFinite(v))) return null;\n return parts;\n}\n\nconst envelopeRaw = read24('envelope_kwh', 'envelope_csv');\nconst envelope = envelopeRaw ? envelopeRaw.map(v => Math.max(0, num(v, 0))) : null;\n\nconst censorshipRaw = read24('censorship_rate', 'censorship_rate_csv');\nconst censorshipRate = censorshipRaw\n ? censorshipRaw.map(v => Math.max(0, Math.min(1, num(v, 1))))\n : Array.from({ length: 24 }, () => 1);\n\nconst hourOfDay = (ms) =>\n Number.parseInt(\n new Intl.DateTimeFormat('fr-FR', {\n timeZone: CFG.TIMEZONE, hour: '2-digit', hour12: false,\n }).format(new Date(ms)),\n 10\n );\n\nconst dayKey = (ms) =>\n new Intl.DateTimeFormat('fr-CA', {\n timeZone: CFG.TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit',\n }).format(new Date(ms));\n\nconst currentHourStart = Math.floor(nowMs / 3600000) * 3600000;\nconst today = dayKey(nowMs);\n\nconst slots = [];\nfor (let i = 0; i < CFG.HORIZON_HOURS; i++) {\n const start = currentHourStart + i * 3600000;\n const hour = hourOfDay(start);\n slots.push({\n startMs: start,\n hour,\n day: dayKey(start),\n weight: envelope ? envelope[hour] : 0,\n quality: censorshipRate[hour] <= CFG.CENSORSHIP_QUALITY_LIMIT ? 'fiable' : 'minoree',\n });\n}\n\n// Répartition de l'énergie prévue au prorata de la forme démontrée.\nfunction distribute(daySlots, energyKwh) {\n const totalWeight = daySlots.reduce((s, x) => s + x.weight, 0);\n for (const slot of daySlots) {\n slot.expectedKwh = totalWeight > 0 ? (slot.weight / totalWeight) * energyKwh : 0;\n }\n}\n\nconst todaySlots = slots.filter(s => s.day === today);\nconst tomorrowSlots = slots.filter(s => s.day !== today);\ndistribute(todaySlots, todayRemainingKwh);\ndistribute(tomorrowSlots, tomorrowKwh);\n\n// Part de l'énergie projetée qui tombe sur des heures dont la forme est minorée :\n// c'est la mesure de confiance de cette projection.\nconst projected = slots.reduce((s, x) => s + x.expectedKwh, 0);\nconst projectedOnWeakShape = slots\n .filter(x => x.quality === 'minoree')\n .reduce((s, x) => s + x.expectedKwh, 0);\n\nreturn [{\n json: {\n generatedAt: new Date(nowMs).toISOString(),\n usable: !!envelope,\n\n horizon: slots.map(s => ({\n startMs: s.startMs,\n hour: s.hour,\n day: s.day,\n expectedKwh: round2(s.expectedKwh),\n shapeQuality: s.quality,\n })),\n\n todayExpectedRemainingKwh: round2(todaySlots.reduce((s, x) => s + x.expectedKwh, 0)),\n tomorrowExpectedKwh: round2(tomorrowSlots.reduce((s, x) => s + x.expectedKwh, 0)),\n\n source: {\n todayRemainingKwh: round2(todayRemainingKwh),\n tomorrowKwh: round2(tomorrowKwh),\n envelopeProvided: !!envelope,\n shapeConfidence: projected > 0 ? round2(1 - projectedOnWeakShape / projected) : null,\n weakShapeHours: slots\n .filter(x => x.quality === 'minoree' && x.expectedKwh > 0)\n .map(x => x.hour)\n .filter((h, i, a) => a.indexOf(h) === i),\n },\n\n cfg: CFG,\n },\n}];\n" + } + }, + { + "name": "PREPARE_BALANCE", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 660, + 120 + ], + "parameters": { + "jsCode": "// EVCS Energy Balance — PREPARE_BALANCE\n// =====================================================================\n// Colle de câblage n8n, et rien d'autre. Les nœuds de calcul (CENSORSHIP,\n// RESOURCE_ESTIMATE, BALANCE) sont volontairement écrits pour ne lire que\n// `$input`, afin de rester testables hors de n8n. Ce nœud rassemble donc ce\n// que BALANCE attend, en puisant dans les nœuds précédents.\n//\n// Il fait aussi la seule conversion nécessaire : les profils horaires vivent\n// dans des helpers `input_text` sous forme de CSV compact (24 valeurs, moins\n// de 255 caractères), il faut les redonner sous forme de tableaux.\n// =====================================================================\n\nfunction parseCsv24(raw) {\n if (typeof raw !== 'string') return null;\n const parts = raw.split(',').map(v => Number.parseFloat(v.trim()));\n if (parts.length !== 24 || parts.some(v => !Number.isFinite(v))) return null;\n return parts;\n}\n\n// Le nœud HTTP de n8n renvoie le corps du template dans `data`, sous forme de\n// chaîne. Hors n8n (tests), l'objet arrive déjà décodé — on accepte les deux.\nfunction unwrapSnapshot(raw) {\n if (raw && typeof raw.data === 'string') {\n try { return JSON.parse(raw.data); } catch { return raw; }\n }\n return (raw && raw.data) || raw;\n}\n\nconst snapshot = unwrapSnapshot($('HA Snapshot Balance').first().json);\nconst resource = $input.first().json;\n\nreturn [{\n json: {\n nowMs: Number.parseFloat(snapshot.nowMs),\n\n consumption_p50: parseCsv24(snapshot.consumption_p50_csv),\n consumption_p80: parseCsv24(snapshot.consumption_p80_csv),\n\n horizon: resource.horizon || [],\n\n soc_percent: snapshot.soc_percent,\n usable_capacity_kwh: snapshot.usable_capacity_kwh,\n // Plancher dur : même valeur de repli que INPUT_PARSE.js\n // (evcs_night_battery_hard_floor_pct n'est aujourd'hui exposé par AUCUN\n // helper — la valeur « 22 % » documentée partout est en réalité un défaut\n // de code, cf. backlog du chantier).\n soc_floor_percent: 22,\n\n // Repris pour l'écriture des helpers, sans influencer le calcul.\n resourceUsable: resource.usable,\n shapeConfidence: resource.source ? resource.source.shapeConfidence : null,\n },\n}];\n" + } + }, + { + "name": "BALANCE", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 880, + 120 + ], + "parameters": { + "jsCode": "// EVCS Energy Balance — BALANCE\n// =====================================================================\n// Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre.\n// Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux\n// contrôleurs liront à la place de leurs constantes.\n//\n// La question à laquelle il répond n'est pas « le SOC est-il au-dessus de\n// 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le\n// solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe\n// et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir\n// de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC —\n// et non un palier arbitraire à 30 %.\n//\n// Entrées (toutes fournies, aucune constante physique en dur) :\n// consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE)\n// consumption_p80[24] idem, valeur prudente\n// horizon[] production attendue heure par heure (RESOURCE_ESTIMATE)\n// soc_percent SOC lissé pour le pilotage\n// usable_capacity_kwh capacité réellement disponible du parc batterie\n// soc_floor_percent plancher dur de sécurité\n//\n// Sorties : heure de croisement · énergie à couvrir · SOC requis · marge\n// disponible pour le véhicule · niveau de confiance.\n// =====================================================================\n\nfunction num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction round1(v) {\n return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;\n}\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : null;\n}\n\nconst input = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la\n // réponse n'aurait plus de sens opérationnel.\n SEARCH_HOURS: 30,\n // PAS de marge de sécurité supplémentaire. La prudence est déjà portée,\n // entièrement, par le choix du percentile du profil de consommation (p80),\n // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées,\n // p80(somme des heures) est égal a somme(p80 par heure) — les heures de\n // consommation sont fortement corrélées, une matinée chargée l'étant sur\n // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux\n // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une\n // constante arbitraire de plus.\n};\n\nfunction profileOf(key) {\n const a = input[key];\n return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null;\n}\nconst consumptionP50 = profileOf('consumption_p50');\nconst consumptionP80 = profileOf('consumption_p80');\n\nconst horizon = Array.isArray(input.horizon) ? input.horizon : null;\n\nconst socPct = num(input.soc_percent, NaN);\nconst usableCapacityKwh = num(input.usable_capacity_kwh, NaN);\nconst socFloorPct = num(input.soc_floor_percent, 22);\n\n// Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les\n// contrôleurs retombent sur leurs règles historiques.\nconst missing = [];\nif (!consumptionP50) missing.push('consumption_p50');\nif (!consumptionP80) missing.push('consumption_p80');\nif (!horizon || !horizon.length) missing.push('horizon');\nif (!Number.isFinite(socPct)) missing.push('soc_percent');\nif (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh');\n\nif (missing.length) {\n return [{\n json: {\n usable: false,\n missing,\n reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`,\n cfg: CFG,\n },\n }];\n}\n\n// ---------------------------------------------------------------------\n// Bilan heure par heure, calculé DEUX FOIS.\n//\n// Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du\n// réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se\n// tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard,\n// le plancher dur garantissant la batterie ; se tromper par excès de prudence\n// coûte un import réseau bien réel, immédiatement.\n//\n// Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan\n// exigerait 48 % de SOC pour franchir la matinée, alors que la consommation\n// réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait\n// été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête\n// qu'il remplace n'a aucun intérêt.\n//\n// - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu.\n// - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement\n// du stockage, choix qui engagent plusieurs heures.\n// ---------------------------------------------------------------------\nfunction buildSlots(consumption) {\n return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => {\n const hour = num(slot.hour, 0);\n const productionKwh = Math.max(0, num(slot.expectedKwh, 0));\n const consumptionKwh = consumption[hour] ?? 0;\n return {\n startMs: slot.startMs,\n hour,\n productionKwh: round2(productionKwh),\n consumptionKwh: round2(consumptionKwh),\n netKwh: round2(productionKwh - consumptionKwh),\n shapeQuality: slot.shapeQuality || 'inconnue',\n };\n });\n}\n\nfunction coverage(slots) {\n const crossingIndex = slots.findIndex(s => s.netKwh > 0);\n const found = crossingIndex >= 0;\n const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;\n const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);\n const socNeededPct = (energyToCoverKwh / usableCapacityKwh) * 100;\n const requiredSocPct = Math.min(100, socFloorPct + socNeededPct);\n const socMarginPct = socPct - requiredSocPct;\n return {\n crossingIndex, found, deficitSlots, slots,\n energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,\n gridNeeded: socMarginPct < 0,\n gridEnergyNeededKwh: socMarginPct < 0 ? (Math.abs(socMarginPct) / 100) * usableCapacityKwh : 0,\n };\n}\n\nconst typical = coverage(buildSlots(consumptionP50));\nconst prudent = coverage(buildSlots(consumptionP80));\n\nconst slots = typical.slots;\nconst crossingFound = typical.found;\nconst crossingIndex = typical.crossingIndex;\nconst crossing = crossingFound ? slots[crossingIndex] : null;\nconst deficitSlots = typical.deficitSlots;\n\n// ---------------------------------------------------------------------\n// Marge disponible pour le véhicule : surplus attendu après le croisement,\n// une fois la maison servie. C'est ce qui peut être chargé sans toucher au\n// réseau ni entamer la réserve.\n// ---------------------------------------------------------------------\nconst surplusAfterCrossingKwh = crossingFound\n ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0)\n : 0;\n\n// ---------------------------------------------------------------------\n// Confiance : la part du calcul qui repose sur une forme de production minorée\n// (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau »,\n// qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées,\n// une heure censurée étant par construction une heure de surplus.\n// ---------------------------------------------------------------------\nconst deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length;\nconst confidence = {\n deficitHoursOnWeakShape: deficitOnWeakShape,\n deficitTrustworthy: deficitOnWeakShape === 0,\n surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0)\n .some(s => s.shapeQuality === 'minoree'),\n};\n\nreturn [{\n json: {\n usable: true,\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n\n crossing: {\n found: crossingFound,\n hour: crossing ? crossing.hour : null,\n startMs: crossing ? crossing.startMs : null,\n hoursFromNow: crossingFound ? crossingIndex : null,\n },\n\n coverage: {\n // Décision réseau immédiate — remplace input_number.victron_grid_soc_off.\n energyToCoverKwh: round2(typical.energyToCoverKwh),\n requiredSocPct: round1(typical.requiredSocPct),\n currentSocPct: round1(socPct),\n socMarginPct: round1(typical.socMarginPct),\n gridNeeded: typical.gridNeeded,\n gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh),\n\n // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau.\n prudent: {\n energyToCoverKwh: round2(prudent.energyToCoverKwh),\n requiredSocPct: round1(prudent.requiredSocPct),\n socMarginPct: round1(prudent.socMarginPct),\n crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null,\n },\n },\n\n vehicle: {\n surplusAvailableKwh: round2(surplusAfterCrossingKwh),\n },\n\n confidence,\n slots,\n\n inputs: {\n usableCapacityKwh: round2(usableCapacityKwh),\n socFloorPct: round1(socFloorPct),\n },\n\n cfg: CFG,\n },\n}];\n" + } + }, + { + "name": "BUILD_HA_CALLS", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1100, + 120 + ], + "parameters": { + "jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS\n// =====================================================================\n// GARANTIE MODE OMBRE : ce nœud ne produit QUE des écritures vers des entités\n// dont le nom commence par `evcs_balance_`, plus une trace logbook. Aucun\n// switch, aucune consigne de borne, aucune stratégie, aucun helper de takeover.\n// Le bilan observe et publie ; il ne pilote rien tant que le câblage réel n'a\n// pas été décidé explicitement.\n//\n// Cette garantie est VÉRIFIÉE par tests/energy-balance/ : la suite échoue si\n// une écriture sort de ce périmètre. Ne pas ajouter d'autres entités ici sans\n// changer le test en connaissance de cause.\n// =====================================================================\n\nconst balance = $input.first().json;\nconst censorship = $('CENSORSHIP').first().json;\nconst rawSnapshot = $('HA Snapshot Balance').first().json;\nconst snapshot = (rawSnapshot && typeof rawSnapshot.data === 'string')\n ? (() => { try { return JSON.parse(rawSnapshot.data); } catch { return rawSnapshot; } })()\n : ((rawSnapshot && rawSnapshot.data) || rawSnapshot);\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\n\nfunction push(domain, service, data) {\n calls.push({ json: { domain, service, data } });\n}\nfunction setText(entity, value) {\n push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n}\nfunction setNumber(entity, value) {\n if (!Number.isFinite(value)) return;\n push('input_number', 'set_value', { entity_id: entity, value });\n}\nfunction setBool(entity, on) {\n push('input_boolean', on ? 'turn_on' : 'turn_off', { entity_id: entity });\n}\n\n// Kill-switch : à OFF, on ne touche à rien du tout, pas même au statut.\nconst enabled = (snapshot.balance_enabled ?? 'on').toString().toLowerCase() === 'on';\nif (!enabled) return [];\n\n// --- Ce que le bilan constate de la production, dans tous les cas ---------\nsetBool('input_boolean.evcs_balance_pv_censored', !!censorship.censorship.pvCensored);\nsetText('input_text.evcs_balance_censor_reason',\n censorship.censorship.pvCensored\n ? censorship.censorship.reasons.join(', ')\n : 'production non bridée');\nsetText('input_text.evcs_balance_last_run', stampParis());\n\n// --- Le bilan lui-même ---------------------------------------------------\nif (!balance.usable) {\n setText('input_text.evcs_balance_status', 'indisponible');\n setText('input_text.evcs_balance_reason',\n `Entrées manquantes : ${(balance.missing || []).join(', ')}. Les contrôleurs gardent leurs règles.`);\n return calls;\n}\n\nconst c = balance.coverage;\n\nsetText('input_text.evcs_balance_status', 'ok');\nsetNumber('input_number.evcs_balance_required_soc', c.requiredSocPct);\nsetNumber('input_number.evcs_balance_required_soc_prudent', c.prudent.requiredSocPct);\nsetNumber('input_number.evcs_balance_energy_to_cover_kwh', c.energyToCoverKwh);\nsetNumber('input_number.evcs_balance_grid_energy_needed_kwh', c.gridEnergyNeededKwh);\nsetNumber('input_number.evcs_balance_vehicle_surplus_kwh', balance.vehicle.surplusAvailableKwh);\nsetBool('input_boolean.evcs_balance_grid_needed', !!c.gridNeeded);\nsetText('input_text.evcs_balance_crossing_hour',\n balance.crossing.found ? `${String(balance.crossing.hour).padStart(2, '0')} h` : 'aucun sur l\\'horizon');\n\nconst reason = balance.crossing.found\n ? `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.'}`\n : `Aucun croisement PV/conso sur l'horizon : ${c.energyToCoverKwh} kWh à couvrir → SOC requis ${c.requiredSocPct}%.`;\nsetText('input_text.evcs_balance_reason', reason);\n\n// Trace longue durée, pour comparer jour après jour le seuil calculé au seuil\n// fixe encore en vigueur — c'est cette comparaison qui décidera du câblage.\npush('logbook', 'log', {\n name: 'EVCS Bilan énergétique (ombre)',\n 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'}`,\n entity_id: 'input_text.evcs_balance_status',\n});\n\nreturn calls;\n" + } + }, + { + "name": "Execute HA Service Balance", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1320, + 120 + ], + "parameters": { + "method": "POST", + "url": "={{ 'http://10.10.10.17:8123/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": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + }, + "onError": "continueRegularOutput" + } + ], + "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 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "callerPolicy": "workflowsFromSameOwner" + }, + "staticData": null, + "id": "QoSfPeyGq35orF1K" +}