Chantier etapes 5b et 5c : recalcul quotidien des profils et fenetre de deficit suivante

5c — BALANCE ne repondait qu'a "combien jusqu'a ce que le solaire prenne le
relais". A 15 h la reponse est zero : exacte mais inutile, le deficit qui
approche etant celui de la nuit. Ajout de la prochaine traversee deficitaire
(heure de debut, energie, SOC a atteindre avant). Ce SOC n'est PAS ecrete a
100 % : au-dela, meme une batterie pleine ne passe pas, et le manque est
publie comme storage_shortfall_kwh — c'est le chiffre du dimensionnement.

5b — Workflow "EVCS Energy Profiles Daily V1" (N6UaB4ZozsbRPrFb), actif,
03:30 : lit /api/history/period sur 30 jours glissants et republie les quatre
CSV (conso p50 et p80, enveloppe de production, taux de censure).
CONSUMPTION_PROFILE devient DAILY_PROFILES et sait desormais decoder la
reponse brute de l'API en plus des series deja pretes.

Piege trouve : sans end_time, /api/history/period ne renvoie qu'UNE journee,
silencieusement. Mes premieres mesures "sur 3 jours" n'en couvraient qu'une.

Validation croisee : profils calcules depuis l'API REST (etats bruts, 11 j)
contre profils calcules depuis les statistiques long terme (28 j) — 0,20 kWh/h
d'ecart absolu moyen, par deux chemins de donnees independants.

BUILD_HA_CALLS_PROFILES refuse d'ecraser les profils en place si l'historique
ne couvre pas les 24 heures. Meme garantie de perimetre que le bilan.
Suite a 27 scenarios.

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:58:10 +02:00
parent e36f050a42
commit 6dc5ea25cb
14 changed files with 668 additions and 22 deletions

View File

@@ -0,0 +1,115 @@
#!/usr/bin/env node
// Génère le squelette du workflow "EVCS Energy Profiles Daily V1" : recalcul
// quotidien des profils horaires (consommation, enveloppe de production, taux
// de censure) à partir de l'historique Home Assistant.
// Le CODE des nodes est injecté depuis src/energy-balance/ par n8n-build.mjs.
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 ENTITIES = [
'sensor.airtria_mpii_v1_energy_from_inverter_to_ac_out',
'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_ac_out',
'sensor.evcs_hq2239dnt7p_total_energy',
'sensor.gx_device_pv_energy',
'sensor.victron_battery_soc',
].join(',');
// Fenêtre demandée : 30 jours. L'API ne renvoie que ce que la rétention
// conserve (10 jours au 24/08, 90 une fois la nouvelle rétention accumulée),
// donc la fenêtre s'élargira d'elle-même sans rien changer ici.
// ⚠️ `end_time` est OBLIGATOIRE : sans lui, /api/history/period ne renvoie
// qu'UNE SEULE journée, silencieusement.
const HISTORY_URL =
"={{ '" + HA_URL + "/api/history/period/' " +
'+ new Date(Date.now() - 30 * 86400000).toISOString() ' +
"+ '?end_time=' + encodeURIComponent(new Date().toISOString()) " +
"+ '&filter_entity_id=" + ENTITIES + "' " +
"+ '&minimal_response&significant_changes_only=0' }}";
const nodes = [
{
name: 'Schedule Daily',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1.2,
position: [0, 0],
parameters: { rule: { interval: [{ field: 'days', triggerAtHour: 3, triggerAtMinute: 30 }] } },
},
{
name: 'HA History',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [220, 0],
parameters: {
method: 'GET',
url: HISTORY_URL,
authentication: 'genericCredentialType',
genericAuthType: 'httpBearerAuth',
options: {
timeout: 120000,
response: { response: { responseFormat: 'text' } },
},
},
credentials: { httpBearerAuth: HA_CRED },
},
{
name: 'DAILY_PROFILES',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [440, 0],
parameters: { jsCode: '// injecté depuis src/energy-balance/DAILY_PROFILES.js' },
},
{
name: 'BUILD_HA_CALLS_PROFILES',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [660, 0],
parameters: { jsCode: '// injecté depuis src/energy-balance/BUILD_HA_CALLS_PROFILES.js' },
},
{
name: 'Execute HA Service Profiles',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [880, 0],
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',
},
];
const connections = {
'Schedule Daily': { main: [[{ node: 'HA History', type: 'main', index: 0 }]] },
'HA History': { main: [[{ node: 'DAILY_PROFILES', type: 'main', index: 0 }]] },
DAILY_PROFILES: { main: [[{ node: 'BUILD_HA_CALLS_PROFILES', type: 'main', index: 0 }]] },
BUILD_HA_CALLS_PROFILES: { main: [[{ node: 'Execute HA Service Profiles', type: 'main', index: 0 }]] },
};
const workflow = {
id: 'N6UaB4ZozsbRPrFb',
name: 'EVCS Energy Profiles Daily V1',
nodes,
connections,
settings: { executionOrder: 'v1', callerPolicy: 'workflowsFromSameOwner' },
staticData: null,
};
fs.writeFileSync('workflows/evcs-energy-profiles-daily-v1.json', JSON.stringify(workflow, null, 2) + '\n');
console.log(`Squelette écrit : workflows/evcs-energy-profiles-daily-v1.json (${nodes.length} nodes)`);

View File

@@ -41,6 +41,15 @@ export const WORKFLOWS = [
{ file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Balance', kind: 'template' },
],
},
{
id: 'energy-profiles',
workflowFile: 'workflows/evcs-energy-profiles-daily-v1.json',
srcDir: 'src/energy-balance',
nodes: [
{ file: 'DAILY_PROFILES.js', node: 'DAILY_PROFILES', kind: 'jsCode' },
{ file: 'BUILD_HA_CALLS_PROFILES.js', node: 'BUILD_HA_CALLS_PROFILES', kind: 'jsCode' },
],
},
];
// Rétro-compatibilité (outils historiques mono-workflow)

View File

@@ -109,7 +109,8 @@ 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 isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-v1.json'
|| path.basename(INPUT) === 'evcs-energy-profiles-daily-v1.json';
const skipGolden = process.argv.includes('--skip-golden');
const testSuite =
isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' :