From 17d65f1e404c68928935e0f2bdee10dc9d076125 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Tue, 25 Aug 2026 11:11:24 +0200 Subject: [PATCH] Bilan : un delta horaire appartient a l'heure qui l'a consomme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ --- dashboards/bilan_energetique.yaml | 18 ++++- src/energy-balance/BUILD_HA_CALLS.js | 21 ++++- src/energy-balance/DAILY_PROFILES.js | 32 +++++++- tests/energy-balance/run.mjs | 6 ++ tests/energy-balance/scenarios-profile.mjs | 49 ++++++++++++ tests/energy-balance/scenarios-shadow.mjs | 26 ++++++ tools/ha-history.mjs | 84 ++++++++++++++++++++ workflows/evcs-energy-balance-v1.json | 2 +- workflows/evcs-energy-profiles-daily-v1.json | 2 +- 9 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 tools/ha-history.mjs diff --git a/dashboards/bilan_energetique.yaml b/dashboards/bilan_energetique.yaml index 518dfc5..3ecc580 100644 --- a/dashboards/bilan_energetique.yaml +++ b/dashboards/bilan_energetique.yaml @@ -13,7 +13,7 @@ views: - type: conditional conditions: - entity: input_text.evcs_balance_status - state_not: "ok" + state: "indisponible" card: type: markdown content: | @@ -23,6 +23,22 @@ views: Les contrôleurs fonctionnent sur leurs règles historiques. + - type: conditional + conditions: + - entity: input_text.evcs_balance_status + state: "degrade" + card: + type: markdown + content: | + ## 🟠 Bilan dégradé + + Les chiffres sont publiés, mais ils reposent sur une base + incomplète : soit aucun croisement production/consommation + n'apparaît sur l'horizon, soit la forme de production des heures + de déficit vient d'un repli sur l'enveloppe mesurée. + + {{ states('input_text.evcs_balance_reason') }} + # --- LA comparaison qui décidera du câblage --------------------- - type: markdown title: Seuil calculé contre seuil fixe diff --git a/src/energy-balance/BUILD_HA_CALLS.js b/src/energy-balance/BUILD_HA_CALLS.js index 5bbe554..bc795b2 100644 --- a/src/energy-balance/BUILD_HA_CALLS.js +++ b/src/energy-balance/BUILD_HA_CALLS.js @@ -65,7 +65,21 @@ if (!balance.usable) { const c = balance.coverage; -setText('input_text.evcs_balance_status', 'ok'); +// `ok` ne veut pas dire « le calcul a abouti » mais « on peut s'appuyer +// dessus ». Deux cas dégradent la réponse sans l'empêcher : +// · aucun croisement production/consommation sur l'horizon — le SOC requis +// n'est alors plus une exigence datée mais la somme d'un horizon entier ; +// · des heures de déficit reposant sur une forme de production minorée +// (repli sur l'enveloppe faute de courbe horaire fraîche). +// Dans les deux cas le bilan publie quand même ses chiffres — ils restent +// informatifs — mais il le DIT, pour qu'aucun câblage futur ne s'y fie en +// silence. Mesuré le 25/08 : sans cette distinction, la nuit précédant la mise +// en service de la courbe horaire, le bilan a publié un SOC requis de 100 % +// avec un statut `ok`. +const degraded = + !balance.crossing.found || + !(balance.confidence && balance.confidence.deficitTrustworthy); +setText('input_text.evcs_balance_status', degraded ? 'degrade' : '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); @@ -93,10 +107,13 @@ const ndText = nd.found ? ` | prochaine traversée ${nd.energyKwh} kWh dès ${nd.startHour} h → SOC requis ${nd.requiredSocAtStartPct}%${nd.feasibleWithCurrentStorage ? '' : ` (manque ${Math.round(nd.storageShortfallKwh * 100) / 100} kWh de stockage)`}` : ''; +const degradedNote = !balance.crossing.found + ? ' ⚠ aucun croisement sur l\'horizon : chiffre indicatif, pas une exigence datée.' + : (degraded ? ' ⚠ forme de production minorée sur les heures de déficit.' : ''); 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 + ndText); +setText('input_text.evcs_balance_reason', reason + degradedNote + ndText); // 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. diff --git a/src/energy-balance/DAILY_PROFILES.js b/src/energy-balance/DAILY_PROFILES.js index a7aba36..c2cd933 100644 --- a/src/energy-balance/DAILY_PROFILES.js +++ b/src/energy-balance/DAILY_PROFILES.js @@ -89,17 +89,37 @@ function bucketHourly(series) { // Deltas entre heures consécutives. Un delta négatif signale une remise à zéro // du compteur (redémarrage HA, réinitialisation d'intégration) : on jette // l'échantillon plutôt que de fabriquer une valeur. +// +// ⚠️ À QUELLE HEURE APPARTIENT UN DELTA ? (corrigé le 25/08/2026) +// `bucketHourly` garde la DERNIÈRE valeur de chaque heure. Le delta entre +// l'heure H−1 et l'heure H est donc l'énergie consommée PENDANT H : il se +// range sous `hours[i]`, jamais sous `hours[i-1]`. +// +// L'erreur d'un cran avait deux effets, l'un discret et l'autre grave : +// · tout le profil horaire était décalé d'une heure vers l'avant ; +// · surtout, les compteurs de cette installation sont ÉPARS — ils n'émettent +// que lorsqu'ils avancent. `ac_in_1_to_ac_out` ne peuple que 57 heures sur +// 267, `evcs_total` 57 sur 264. Une heure vide signifie « valeur inchangée, +// donc zéro énergie » : toute l'énergie d'un intervalle à trou a été +// consommée pendant l'heure FINALE, celle qui a réveillé le compteur. +// En la rangeant sous l'heure initiale, l'import réseau de la nuit suivante +// atterrissait sur la dernière heure de la nuit précédente. Mesuré le +// 25/08 : `consumption_p50[8] = 2,90 kWh/h` pour une consommation réelle de +// 0,9 — six échantillons sur douze portaient un delta étalé sur 17 heures. +// Le bilan réclamait alors deux fois trop d'énergie le matin. function deltaSeries(buckets) { const hours = [...buckets.keys()].sort((a, b) => a - b); const deltas = new Map(); let resets = 0; + let spannedGaps = 0; for (let i = 1; i < hours.length; i++) { const d = buckets.get(hours[i]).value - buckets.get(hours[i - 1]).value; if (d < 0) { resets++; continue; } if (d > CFG.MAX_PLAUSIBLE_HOURLY_KWH) { resets++; continue; } - deltas.set(hours[i - 1], d); + if (hours[i] - hours[i - 1] > 3600000 && d > 0) spannedGaps++; + deltas.set(hours[i], d); } - return { deltas, resets }; + return { deltas, resets, spannedGaps }; } // --------------------------------------------------------------------- @@ -261,6 +281,14 @@ return [{ socHoursUsed: socHourly.size, hoursWithEvcs: evcsHours, countersReset: fromInverter.resets + fromGrid.resets + fromEvcs.resets, + // Nombre de deltas couvrant plus d'une heure : c'est le signe que le + // compteur est resté silencieux (donc à l'arrêt). L'énergie est rangée + // sous l'heure qui l'a réveillé. Un chiffre élevé n'est pas une anomalie + // sur cette installation — le réseau et la borne sont éteints la plupart + // du temps — mais il faut le voir pour ne pas relire un profil sans le + // savoir. + spannedGaps: + fromInverter.spannedGaps + fromGrid.spannedGaps + fromEvcs.spannedGaps, unreliableHours: profile.filter(h => !h.reliable).map(h => h.hour), }, cfg: CFG, diff --git a/tests/energy-balance/run.mjs b/tests/energy-balance/run.mjs index 1374a24..06f9538 100644 --- a/tests/energy-balance/run.mjs +++ b/tests/energy-balance/run.mjs @@ -56,6 +56,11 @@ const SUMMARIZE = { csvFitsHelper: o.csv.p80.length <= 255 && o.csv.p50.length <= 255, p50AtHour14: o.profile[14].p50, p80AtHour14: o.profile[14].p80, + // Heures 1 à 3 et 8 : sert au scénario d'attribution horaire, où un + // compteur épars ne se réveille que la nuit. + p50Hours1a3: o.profile.slice(1, 4).map(h => h.p50).join(','), + p50AtHour8: o.profile[8].p50, + spannedGaps: o.quality.spannedGaps, }), RESOURCE_ESTIMATE: o => ({ usable: o.usable, @@ -93,6 +98,7 @@ SUMMARIZE.BUILD_HA_CALLS = (calls) => { wroteGridNeeded: entities.includes('input_boolean.evcs_balance_grid_needed'), hasLogbook: calls.some(c => c.json.domain === 'logbook'), wroteProfiles: entities.includes('input_text.evcs_balance_consumption_p50'), + status: (calls.find(c => c.json.data.entity_id === 'input_text.evcs_balance_status') || { json: { data: {} } }).json.data.value, }; }; SUMMARIZE.BUILD_HA_CALLS_PROFILES = SUMMARIZE.BUILD_HA_CALLS; diff --git a/tests/energy-balance/scenarios-profile.mjs b/tests/energy-balance/scenarios-profile.mjs index 044888f..084107f 100644 --- a/tests/energy-balance/scenarios-profile.mjs +++ b/tests/energy-balance/scenarios-profile.mjs @@ -83,6 +83,55 @@ export const profileScenarios = [ unreliableHours: '0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23', }, }, + { + node: 'DAILY_PROFILES', + id: 'compteur-epars-attribution-horaire', + label: + 'Compteur épars (réseau allumé seulement la nuit) : chaque kWh se range sous l\'heure qui l\'a consommé', + // Reproduit le défaut mesuré le 25/08/2026 : `ac_in_1_to_ac_out` n'émet que + // pendant les heures creuses. Entre deux rafales, le compteur reste muet + // pendant 21 heures. L'énergie d'un intervalle à trou appartient à l'heure + // qui a réveillé le compteur, pas à celle où il s'était endormi. + snapshot: (() => { + const inverter = []; + const grid = []; + let cumInverter = 0; + let cumGrid = 0; + const start = Date.parse('2026-08-01T00:00:00+02:00'); + // Rafale réseau : 1 kWh à 01 h, 2 kWh à 02 h, 3 kWh à 03 h. Montants + // distincts pour qu'un décalage d'un cran soit impossible à confondre. + const burst = { 1: 1, 2: 2, 3: 3 }; + for (let h = 0; h < 12 * 24; h++) { + const ts = new Date(start + h * 3600000); + const hourParis = Number.parseInt( + new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', hour: '2-digit', hour12: false, + }).format(ts), + 10 + ); + cumInverter += 0.8; + inverter.push({ ts: ts.toISOString(), state: String(cumInverter.toFixed(2)) }); + const kwh = burst[hourParis]; + if (kwh) { + cumGrid += kwh; + // Le compteur n'émet QUE pendant les heures où il avance. + grid.push({ ts: ts.toISOString(), state: String(cumGrid.toFixed(2)) }); + } + } + return { + series: { inverter_to_ac_out: inverter, ac_in_to_ac_out: grid, evcs_total: [] }, + nowMs: NOW, + }; + })(), + expect: { + // 0,8 de fond + la rafale de l'heure : 1,8 / 2,8 / 3,8. + p50Hours1a3: '1.8,2.8,3.8', + // 08 h ne consomme que le fond : c'est l'heure qui portait 2,90 kWh/h + // en production avant correction. + p50AtHour8: 0.8, + complete: true, + }, + }, ]; // --- RESOURCE_ESTIMATE ------------------------------------------------- diff --git a/tests/energy-balance/scenarios-shadow.mjs b/tests/energy-balance/scenarios-shadow.mjs index 2b2240c..e130c9e 100644 --- a/tests/energy-balance/scenarios-shadow.mjs +++ b/tests/energy-balance/scenarios-shadow.mjs @@ -29,6 +29,16 @@ const BALANCE_OK = { prudent: { requiredSocPct: 38.1 }, }, vehicle: { surplusAvailableKwh: 20.17 }, + confidence: { deficitTrustworthy: true }, +}; + +// Le même bilan, mais sans croisement trouvé sur l'horizon : le SOC requis +// n'est plus une exigence datée, seulement la somme d'un horizon entier. Le +// statut doit le dire. +const BALANCE_SANS_CROISEMENT = { + ...BALANCE_OK, + crossing: { found: false, hour: null }, + coverage: { ...BALANCE_OK.coverage, requiredSocPct: 100, energyToCoverKwh: 25.97 }, }; export const shadowScenarios = [ @@ -45,6 +55,22 @@ export const shadowScenarios = [ wroteRequiredSoc: true, wroteGridNeeded: true, hasLogbook: true, + status: 'ok', + }, + }, + { + node: 'BUILD_HA_CALLS', + id: 'ombre-sans-croisement-statut-degrade', + label: + 'Aucun croisement sur l\'horizon : les chiffres sont publiés mais le statut passe à `degrade`', + snapshot: { + ...BALANCE_SANS_CROISEMENT, + __nodes: { 'HA Snapshot Balance': SNAPSHOT, CENSORSHIP: CENSORSHIP_LIBRE }, + }, + expect: { + outOfScope: '', + wroteRequiredSoc: true, + status: 'degrade', }, }, { diff --git a/tools/ha-history.mjs b/tools/ha-history.mjs new file mode 100644 index 0000000..5119d40 --- /dev/null +++ b/tools/ha-history.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// Usage: node tools/ha-history.mjs [,...] [debut] [fin] +// node tools/ha-history.mjs sensor.victron_battery_soc 2026-08-25T00:00 2026-08-25T10:00 +// node tools/ha-history.mjs input_boolean.evcs_advisor_apply_night_target -48h +// +// Lit l'historique d'états via l'API REST de Home Assistant (jeton dans .env, +// même mécanique que tools/ha-service.mjs). +// +// ⚠️ PIÈGE VÉRIFIÉ : /api/history/period ne renvoie QU'UNE SEULE JOURNÉE si +// end_time est omis, sans le signaler. Ce script impose donc toujours les deux +// bornes. +// +// Les MCP du projet ne donnent que l'état courant : pour toute analyse +// a posteriori (qui a changé quoi, et quand), il faut l'historique. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function loadToken() { + if (process.env.HA_TOKEN) return process.env.HA_TOKEN; + const envPath = path.join(ROOT, '.env'); + if (!fs.existsSync(envPath)) { + console.error('Jeton introuvable : ni $HA_TOKEN, ni fichier .env.'); + process.exit(1); + } + for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { + const m = line.match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/); + if (m) return m[1].replace(/^["']|["']$/g, ''); + } + console.error('HA_TOKEN absent du fichier .env'); + process.exit(1); +} + +const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123'; +const entities = (process.argv[2] || '').split(',').filter(Boolean); +if (!entities.length) { + console.error('Usage: node tools/ha-history.mjs [,...] [debut|-Nh] [fin]'); + process.exit(1); +} + +function parseBound(arg, fallback) { + if (!arg) return fallback; + const rel = arg.match(/^-(\d+)h$/); + if (rel) return new Date(Date.now() - Number(rel[1]) * 3600e3); + return new Date(arg); +} + +const end = parseBound(process.argv[4], new Date()); +const start = parseBound(process.argv[3], new Date(end.getTime() - 24 * 3600e3)); + +const url = + `${HA_URL}/api/history/period/${start.toISOString()}` + + `?end_time=${encodeURIComponent(end.toISOString())}` + + `&filter_entity_id=${encodeURIComponent(entities.join(','))}` + + `&minimal_response&significant_changes_only`; + +const res = await fetch(url, { headers: { Authorization: `Bearer ${loadToken()}` } }); +if (!res.ok) { + console.error(`HTTP ${res.status} — ${await res.text()}`); + process.exit(1); +} +const series = await res.json(); + +const fmt = (iso) => + new Date(iso).toLocaleString('fr-FR', { + timeZone: 'Europe/Paris', + day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', + }); + +console.log(`Fenêtre : ${fmt(start.toISOString())} → ${fmt(end.toISOString())} (heure de Paris)\n`); +for (const rows of series) { + if (!rows.length) continue; + console.log(`=== ${rows[0].entity_id} (${rows.length} points) ===`); + let prev = null; + for (const r of rows) { + if (r.state === prev) continue; + console.log(` ${fmt(r.last_changed || r.last_updated)} ${r.state}`); + prev = r.state; + } + console.log(''); +} diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index d451bde..2ffe17a 100644 --- a/workflows/evcs-energy-balance-v1.json +++ b/workflows/evcs-energy-balance-v1.json @@ -123,7 +123,7 @@ 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);\n\n// Prochaine traversée déficitaire : ce que la décision immédiate ne montre pas.\nconst nd = balance.nextDeficit || { found: false };\nif (nd.found) {\n setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh);\n setNumber('input_number.evcs_balance_next_deficit_required_soc', nd.requiredSocAtStartPct);\n setNumber('input_number.evcs_balance_storage_shortfall_kwh', nd.storageShortfallKwh);\n setText('input_text.evcs_balance_next_deficit_window',\n `${String(nd.startHour).padStart(2, '0')} h → ${nd.endHour == null ? '?' : String(nd.endHour).padStart(2, '0') + ' h'}`\n + (nd.feasibleWithCurrentStorage ? '' : ' (stockage insuffisant)'));\n} else {\n setText('input_text.evcs_balance_next_deficit_window',\n nd.reason === 'deficit_en_cours' ? 'déficit en cours' : 'aucune sur l\\'horizon');\n}\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 ndText = nd.found\n ? ` | prochaine traversée ${nd.energyKwh} kWh dès ${nd.startHour} h → SOC requis ${nd.requiredSocAtStartPct}%${nd.feasibleWithCurrentStorage ? '' : ` (manque ${Math.round(nd.storageShortfallKwh * 100) / 100} kWh de stockage)`}`\n : '';\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 + ndText);\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" + "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\n// `ok` ne veut pas dire « le calcul a abouti » mais « on peut s'appuyer\n// dessus ». Deux cas dégradent la réponse sans l'empêcher :\n// · aucun croisement production/consommation sur l'horizon — le SOC requis\n// n'est alors plus une exigence datée mais la somme d'un horizon entier ;\n// · des heures de déficit reposant sur une forme de production minorée\n// (repli sur l'enveloppe faute de courbe horaire fraîche).\n// Dans les deux cas le bilan publie quand même ses chiffres — ils restent\n// informatifs — mais il le DIT, pour qu'aucun câblage futur ne s'y fie en\n// silence. Mesuré le 25/08 : sans cette distinction, la nuit précédant la mise\n// en service de la courbe horaire, le bilan a publié un SOC requis de 100 %\n// avec un statut `ok`.\nconst degraded =\n !balance.crossing.found ||\n !(balance.confidence && balance.confidence.deficitTrustworthy);\nsetText('input_text.evcs_balance_status', degraded ? 'degrade' : '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);\n\n// Prochaine traversée déficitaire : ce que la décision immédiate ne montre pas.\nconst nd = balance.nextDeficit || { found: false };\nif (nd.found) {\n setNumber('input_number.evcs_balance_next_deficit_kwh', nd.energyKwh);\n setNumber('input_number.evcs_balance_next_deficit_required_soc', nd.requiredSocAtStartPct);\n setNumber('input_number.evcs_balance_storage_shortfall_kwh', nd.storageShortfallKwh);\n setText('input_text.evcs_balance_next_deficit_window',\n `${String(nd.startHour).padStart(2, '0')} h → ${nd.endHour == null ? '?' : String(nd.endHour).padStart(2, '0') + ' h'}`\n + (nd.feasibleWithCurrentStorage ? '' : ' (stockage insuffisant)'));\n} else {\n setText('input_text.evcs_balance_next_deficit_window',\n nd.reason === 'deficit_en_cours' ? 'déficit en cours' : 'aucune sur l\\'horizon');\n}\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 ndText = nd.found\n ? ` | prochaine traversée ${nd.energyKwh} kWh dès ${nd.startHour} h → SOC requis ${nd.requiredSocAtStartPct}%${nd.feasibleWithCurrentStorage ? '' : ` (manque ${Math.round(nd.storageShortfallKwh * 100) / 100} kWh de stockage)`}`\n : '';\n\nconst degradedNote = !balance.crossing.found\n ? ' ⚠ aucun croisement sur l\\'horizon : chiffre indicatif, pas une exigence datée.'\n : (degraded ? ' ⚠ forme de production minorée sur les heures de déficit.' : '');\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 + degradedNote + ndText);\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" } }, { diff --git a/workflows/evcs-energy-profiles-daily-v1.json b/workflows/evcs-energy-profiles-daily-v1.json index cb6414a..a5c745c 100644 --- a/workflows/evcs-energy-profiles-daily-v1.json +++ b/workflows/evcs-energy-profiles-daily-v1.json @@ -59,7 +59,7 @@ 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" + "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" } }, {