{ "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" }