Files
EVCS/workflows/evcs-energy-profiles-daily-v1.json
Tallagrand Thierry 17d65f1e40 Bilan : un delta horaire appartient a l'heure qui l'a consomme
`bucketHourly` garde la derniere valeur de chaque heure : le delta entre H-1
et H est l'energie depensee PENDANT H. `deltaSeries` le rangeait sous H-1.

Discret sur un compteur dense, catastrophique sur un compteur epars — et les
compteurs de cette installation le sont : `ac_in_1_to_ac_out` ne peuple que 57
heures sur 267, `evcs_total` 57 sur 264, parce qu'ils n'emettent que lorsqu'ils
avancent. Une heure vide vaut « zero energie », donc toute l'energie d'un
intervalle a trou a ete consommee pendant l'heure FINALE. Rangee sous l'heure
initiale, l'import reseau de la nuit suivante atterrissait sur la derniere
heure de la nuit precedente.

Mesure du 25/08 : consumption_p50[8] = 2,90 kWh/h pour 0,80 reellement — six
echantillons sur douze portaient un delta etale sur 17 heures. Le bilan
reclamait environ deux fois trop d'energie le matin (3,37 kWh annonces a 06 h
contre 1,35 mesures), et donc un SOC requis de 44,5 % la ou 31 % suffisait.
L'enveloppe de production etait decalee de la meme heure, ce qui a contribue a
surestimer la part matinale de la production dans le journal de chantier.

Ajoute aussi un statut `degrade` : le bilan publie ses chiffres quand aucun
croisement n'apparait sur l'horizon ou quand les heures de deficit reposent sur
une forme minoree, mais il ne les presente plus comme fiables. La nuit du 25/08,
avant la mise en service de la courbe horaire, il a publie un SOC requis de
100 % avec un statut `ok`.

Tests 36 -> 37, dont `compteur-epars-attribution-horaire` (rafale reseau de
1/2/3 kWh a 01/02/03 h : montants distincts pour qu'un decalage d'un cran soit
impossible a confondre). Mutation verifiee : l'ancienne attribution le fait
echouer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-25 11:11:24 +02:00

172 lines
20 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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.\n//\n// ⚠️ À QUELLE HEURE APPARTIENT UN DELTA ? (corrigé le 25/08/2026)\n// `bucketHourly` garde la DERNIÈRE valeur de chaque heure. Le delta entre\n// l'heure H−1 et l'heure H est donc l'énergie consommée PENDANT H : il se\n// range sous `hours[i]`, jamais sous `hours[i-1]`.\n//\n// L'erreur d'un cran avait deux effets, l'un discret et l'autre grave :\n// · tout le profil horaire était décalé d'une heure vers l'avant ;\n// · surtout, les compteurs de cette installation sont ÉPARS — ils n'émettent\n// que lorsqu'ils avancent. `ac_in_1_to_ac_out` ne peuple que 57 heures sur\n// 267, `evcs_total` 57 sur 264. Une heure vide signifie « valeur inchangée,\n// donc zéro énergie » : toute l'énergie d'un intervalle à trou a été\n// consommée pendant l'heure FINALE, celle qui a réveillé le compteur.\n// En la rangeant sous l'heure initiale, l'import réseau de la nuit suivante\n// atterrissait sur la dernière heure de la nuit précédente. Mesuré le\n// 25/08 : `consumption_p50[8] = 2,90 kWh/h` pour une consommation réelle de\n// 0,9 — six échantillons sur douze portaient un delta étalé sur 17 heures.\n// Le bilan réclamait alors deux fois trop d'énergie le matin.\nfunction deltaSeries(buckets) {\n const hours = [...buckets.keys()].sort((a, b) => a - b);\n const deltas = new Map();\n let resets = 0;\n let spannedGaps = 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 if (hours[i] - hours[i - 1] > 3600000 && d > 0) spannedGaps++;\n deltas.set(hours[i], d);\n }\n return { deltas, resets, spannedGaps };\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 // Nombre de deltas couvrant plus d'une heure : c'est le signe que le\n // compteur est resté silencieux (donc à l'arrêt). L'énergie est rangée\n // sous l'heure qui l'a réveillé. Un chiffre élevé n'est pas une anomalie\n // sur cette installation — le réseau et la borne sont éteints la plupart\n // du temps — mais il faut le voir pour ne pas relire un profil sans le\n // savoir.\n spannedGaps:\n fromInverter.spannedGaps + fromGrid.spannedGaps + fromEvcs.spannedGaps,\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"
}