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