Files
EVCS/workflows/evcs-energy-profiles-daily-v1.json
Tallagrand Thierry 6dc5ea25cb 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
2026-08-24 15:58:10 +02:00

172 lines
18 KiB
JSON

{
"name": "EVCS Energy Profiles Daily V1",
"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": "={{ 'http://10.10.10.17:8123/api/history/period/' + new Date(Date.now() - 30 * 86400000).toISOString() + '?end_time=' + encodeURIComponent(new Date().toISOString()) + '&filter_entity_id=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' + '&minimal_response&significant_changes_only=0' }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"options": {
"timeout": 120000,
"response": {
"response": {
"responseFormat": "text"
}
}
}
},
"credentials": {
"httpBearerAuth": {
"id": "uv3sknYYigxBkaVN",
"name": "Bearer Auth account 2"
}
}
},
{
"name": "DAILY_PROFILES",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
0
],
"parameters": {
"jsCode": "// EVCS Energy Balance — DAILY_PROFILES\n// =====================================================================\n// Rôle : recalculer une fois par jour, à partir de l'historique Home Assistant,\n// les quatre profils horaires dont le bilan a besoin :\n// 1. consommation typique (p50) et prudente (p80), hors véhicule ;\n// 2. enveloppe de production démontrée (production maximale observée) ;\n// 3. taux de censure par heure (part des heures où la batterie était pleine).\n//\n// Tous sont publiés en CSV compact de 24 valeurs dans des helpers input_text\n// (moins de 255 caractères), relus à chaque cycle par le bilan.\n//\n// « La consommation se lit, elle ne se devine pas. » Contrairement à la\n// production, la mesure de consommation n'est PAS censurée : elle ne dépend\n// d'aucune capacité d'absorption. C'est donc la seule des deux grandeurs qu'on\n// peut profiler directement.\n//\n// Consommation maison = énergie livrée aux charges AC (depuis l'onduleur ET en\n// transit depuis le réseau) MOINS l'énergie prise par la borne de recharge.\n// On retire l'EVCS parce que la voiture est une charge pilotable : elle ne doit\n// pas polluer le profil de la charge subie, sinon le système se croit\n// structurellement plus gourmand qu'il ne l'est les jours où la voiture charge.\n//\n// Méthode : ce n'est PAS une moyenne glissante toutes heures confondues. Pour\n// chaque heure de la journée on constitue la distribution des valeurs observées\n// à cette heure-là sur tous les jours de l'historique, puis on en tire des\n// percentiles. Le p80 est la valeur prudente (« une journée sur cinq consomme\n// plus que ça »), le p50 la valeur typique.\n//\n// Entrée : réponse brute de /api/history/period (tableau de tableaux, le\n// premier point de chaque liste portant son entity_id), reçue soit\n// décodée, soit sous forme de chaîne dans `data` selon le réglage du\n// nœud HTTP. Les tests peuvent fournir directement `series`.\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\nconst raw = $input.first().json;\n\nconst CFG = {\n TIMEZONE: 'Europe/Paris',\n // Correspondance entité → rôle. Les seules entités que ce nœud sait lire.\n ENTITIES: {\n 'sensor.airtria_mpii_v1_energy_from_inverter_to_ac_out': 'inverter_to_ac_out',\n 'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_ac_out': 'ac_in_to_ac_out',\n 'sensor.evcs_hq2239dnt7p_total_energy': 'evcs_total',\n 'sensor.gx_device_pv_energy': 'pv_energy',\n 'sensor.victron_battery_soc': 'soc',\n },\n // SOC à partir duquel l'heure est comptée comme censurée : la batterie n'est\n // plus un puits, la production mesurée ne dit plus rien de la ressource.\n CENSORSHIP_SOC_PCT: 90,\n // Un delta horaire au-delà de cette valeur n'est pas une consommation\n // plausible sur cette installation : c'est un artefact de compteur.\n MAX_PLAUSIBLE_HOURLY_KWH: 50,\n // En dessous de ce nombre de jours observés pour une heure donnée, le\n // percentile n'a pas de sens : on le signale au lieu de le publier en silence.\n MIN_DAYS_FOR_PERCENTILE: 5,\n};\n\n// ---------------------------------------------------------------------\n// 1. Regroupement horaire d'une série de compteur cumulé.\n// On garde la DERNIÈRE valeur de chaque heure : le delta entre deux heures\n// consécutives est l'énergie consommée pendant l'heure écoulée.\n// ---------------------------------------------------------------------\nfunction hourBucketMs(ms) {\n return Math.floor(ms / 3600000) * 3600000;\n}\n\nfunction bucketHourly(series) {\n const buckets = new Map();\n for (const point of series || []) {\n const ms = Date.parse(point.ts ?? point.last_changed ?? point.last_updated);\n const value = num(point.state ?? point.value);\n if (!Number.isFinite(ms) || !Number.isFinite(value)) continue;\n const h = hourBucketMs(ms);\n const prev = buckets.get(h);\n if (!prev || ms >= prev.ms) buckets.set(h, { ms, value });\n }\n return buckets;\n}\n\n// Deltas entre heures consécutives. Un delta négatif signale une remise à zéro\n// du compteur (redémarrage HA, réinitialisation d'intégration) : on jette\n// l'échantillon plutôt que de fabriquer une valeur.\nfunction deltaSeries(buckets) {\n const hours = [...buckets.keys()].sort((a, b) => a - b);\n const deltas = new Map();\n let resets = 0;\n for (let i = 1; i < hours.length; i++) {\n const d = buckets.get(hours[i]).value - buckets.get(hours[i - 1]).value;\n if (d < 0) { resets++; continue; }\n if (d > CFG.MAX_PLAUSIBLE_HOURLY_KWH) { resets++; continue; }\n deltas.set(hours[i - 1], d);\n }\n return { deltas, resets };\n}\n\n// ---------------------------------------------------------------------\n// 0. Normalisation de l'entrée : réponse /api/history OU séries déjà prêtes.\n// ---------------------------------------------------------------------\nfunction decode(rawInput) {\n if (rawInput && rawInput.series) return rawInput.series;\n\n let history = rawInput && rawInput.history ? rawInput.history : rawInput;\n if (history && typeof history.data === 'string') {\n try { history = JSON.parse(history.data); } catch { history = null; }\n } else if (history && history.data) {\n history = history.data;\n }\n if (!Array.isArray(history)) return {};\n\n const series = {};\n for (const list of history) {\n if (!Array.isArray(list) || !list.length) continue;\n const role = CFG.ENTITIES[list[0].entity_id];\n if (!role) continue;\n // /api/history en mode minimal ne répète pas entity_id : chaque point porte\n // seulement `state` et `last_changed`.\n series[role] = list.map(p => ({ ts: p.last_changed || p.last_updated, state: p.state }));\n }\n return series;\n}\n\nconst series = decode(raw);\nconst input = { series, nowMs: raw && raw.nowMs };\n\nconst fromInverter = deltaSeries(bucketHourly(series.inverter_to_ac_out));\nconst fromGrid = deltaSeries(bucketHourly(series.ac_in_to_ac_out));\nconst fromEvcs = deltaSeries(bucketHourly(series.evcs_total));\nconst fromPv = deltaSeries(bucketHourly(series.pv_energy));\n\n// SOC : moyenne des relevés de chaque heure. Sert uniquement à savoir si\n// l'heure était censurée, pas à un calcul d'énergie.\nfunction hourlyMean(seriesPoints) {\n const acc = new Map();\n for (const point of seriesPoints || []) {\n const ms = Date.parse(point.ts ?? point.last_changed ?? point.last_updated);\n const value = num(point.state ?? point.value);\n if (!Number.isFinite(ms) || !Number.isFinite(value)) continue;\n const h = hourBucketMs(ms);\n const prev = acc.get(h) || { sum: 0, n: 0 };\n prev.sum += value; prev.n += 1;\n acc.set(h, prev);\n }\n const out = new Map();\n for (const [h, v] of acc) out.set(h, v.sum / v.n);\n return out;\n}\n\nconst socHourly = hourlyMean(series.soc);\n\n// ---------------------------------------------------------------------\n// 2. Consommation maison par heure, hors véhicule.\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 byHourOfDay = Array.from({ length: 24 }, () => []);\nconst days = new Set();\nlet evcsHours = 0;\n\nfor (const [ms, inverterKwh] of fromInverter.deltas) {\n const gridKwh = fromGrid.deltas.get(ms) ?? 0;\n const evcsKwh = fromEvcs.deltas.get(ms) ?? 0;\n if (evcsKwh > 0) evcsHours++;\n const houseKwh = Math.max(0, inverterKwh + gridKwh - evcsKwh);\n byHourOfDay[hourOfDay(ms)].push(houseKwh);\n days.add(new Date(ms).toISOString().slice(0, 10));\n}\n\n// ---------------------------------------------------------------------\n// 2bis. Enveloppe de production et taux de censure.\n//\n// L'enveloppe est le maximum horaire jamais observé. C'est une borne sûre :\n// une heure censurée ne peut pas produire un maximum, donc le maximum vient\n// forcément d'une heure où le système avait de la place.\n// ---------------------------------------------------------------------\nconst envelope = Array.from({ length: 24 }, () => 0);\nconst censusTotal = Array.from({ length: 24 }, () => 0);\nconst censusCensored = Array.from({ length: 24 }, () => 0);\n\nfor (const [ms, pvKwh] of fromPv.deltas) {\n const h = hourOfDay(ms);\n if (pvKwh > envelope[h]) envelope[h] = pvKwh;\n const soc = socHourly.get(ms);\n if (soc === undefined) continue;\n censusTotal[h]++;\n if (soc >= CFG.CENSORSHIP_SOC_PCT) censusCensored[h]++;\n}\n\n// Sans échantillon pour une heure, on suppose la pire hypothèse (censurée) :\n// le bilan signalera une forme minorée plutôt que de la croire fiable.\nconst censorshipRate = censusTotal.map((n, h) => (n ? censusCensored[h] / n : 1));\n\n// ---------------------------------------------------------------------\n// 3. Percentiles par heure de la journée.\n// ---------------------------------------------------------------------\nfunction percentile(values, p) {\n if (!values.length) return null;\n const sorted = [...values].sort((a, b) => a - b);\n return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];\n}\n\nconst profile = byHourOfDay.map((values, hour) => ({\n hour,\n n: values.length,\n p20: round2(percentile(values, 0.2)),\n p50: round2(percentile(values, 0.5)),\n p80: round2(percentile(values, 0.8)),\n reliable: values.length >= CFG.MIN_DAYS_FOR_PERCENTILE,\n}));\n\nconst complete = profile.every(h => h.reliable);\n\n// CSV compact pour un helper input_text (limite 255 caractères) : 24 valeurs\n// au format 0.00, soit ~120 caractères. Le p50 et le p80 vont dans deux helpers\n// distincts, le choix du percentile appartient au consommateur.\nconst csv = (key) => profile.map(h => (h[key] == null ? '' : h[key].toFixed(2))).join(',');\n\nconst dailyTotal = (key) =>\n round2(profile.reduce((sum, h) => sum + (h[key] ?? 0), 0));\n\nreturn [{\n json: {\n generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(),\n days: days.size,\n profile,\n complete,\n csv: {\n p50: csv('p50'),\n p80: csv('p80'),\n envelope: envelope.map(v => v.toFixed(2)).join(','),\n censorshipRate: censorshipRate.map(v => v.toFixed(2)).join(','),\n },\n\n production: {\n envelopeKwh: envelope.map(round2),\n censorshipRate: censorshipRate.map(v => Math.round(v * 100) / 100),\n demonstratedDailyKwh: round2(envelope.reduce((a, b) => a + b, 0)),\n censoredDayHours: censorshipRate.filter((r, h) => envelope[h] > 0.2 && r > 0.25).length,\n },\n totals: {\n typicalDailyKwh: dailyTotal('p50'),\n prudentDailyKwh: dailyTotal('p80'),\n },\n quality: {\n hoursUsed: fromInverter.deltas.size,\n pvHoursUsed: fromPv.deltas.size,\n socHoursUsed: socHourly.size,\n hoursWithEvcs: evcsHours,\n countersReset: fromInverter.resets + fromGrid.resets + fromEvcs.resets,\n unreliableHours: profile.filter(h => !h.reliable).map(h => h.hour),\n },\n cfg: CFG,\n },\n}];\n"
}
},
{
"name": "BUILD_HA_CALLS_PROFILES",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
0
],
"parameters": {
"jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS_PROFILES\n// =====================================================================\n// Publie les quatre profils recalculés dans leurs helpers input_text.\n//\n// MÊME GARANTIE DE PÉRIMÈTRE que le workflow de bilan : ce nœud n'écrit que\n// vers des entités `evcs_balance_*`, plus une trace logbook. La suite de tests\n// échoue si une écriture en sort.\n//\n// Refus explicite de publier un profil douteux : si l'historique est trop court\n// pour couvrir les 24 heures, on n'écrase PAS les profils en place. Mieux vaut\n// un profil un peu ancien qu'un profil creux.\n// =====================================================================\n\nconst profiles = $input.first().json;\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 = [];\nfunction setText(entity, value) {\n calls.push({ json: {\n domain: 'input_text', service: 'set_value',\n data: { entity_id: entity, value: (value ?? '').toString().slice(0, 250) },\n } });\n}\n\nif (!profiles.complete) {\n setText('input_text.evcs_balance_status',\n `profils NON mis à jour : ${profiles.quality.unreliableHours.length} h sans assez de jours observés`);\n calls.push({ json: {\n domain: 'logbook', service: 'log',\n data: {\n name: 'EVCS Bilan — profils quotidiens',\n message: `Recalcul refusé : historique insuffisant (${profiles.days} jour(s), heures creuses : ${profiles.quality.unreliableHours.join(', ')}). Profils précédents conservés.`,\n entity_id: 'input_text.evcs_balance_status',\n },\n } });\n return calls;\n}\n\nsetText('input_text.evcs_balance_consumption_p50', profiles.csv.p50);\nsetText('input_text.evcs_balance_consumption_p80', profiles.csv.p80);\nsetText('input_text.evcs_balance_envelope', profiles.csv.envelope);\nsetText('input_text.evcs_balance_censorship_rate', profiles.csv.censorshipRate);\nsetText('input_text.evcs_balance_last_run', stampParis());\n\ncalls.push({ json: {\n domain: 'logbook', service: 'log',\n data: {\n name: 'EVCS Bilan — profils quotidiens',\n message: `Profils recalculés sur ${profiles.days} jours (${profiles.quality.hoursUsed} h de consommation, ${profiles.quality.pvHoursUsed} h de production, ${profiles.quality.countersReset} remise(s) à zéro écartée(s)). Consommation typique ${profiles.totals.typicalDailyKwh} kWh/j, prudente ${profiles.totals.prudentDailyKwh} kWh/j. Production démontrée ${profiles.production.demonstratedDailyKwh} kWh/j, ${profiles.production.censoredDayHours} heure(s) de jour censurée(s).`,\n entity_id: 'input_text.evcs_balance_consumption_p50',\n },\n} });\n\nreturn calls;\n"
}
},
{
"name": "Execute HA Service Profiles",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
880,
0
],
"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 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
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"callerPolicy": "workflowsFromSameOwner"
},
"staticData": null,
"id": "N6UaB4ZozsbRPrFb"
}