// EVCS Energy Balance — DAILY_PROFILES // ===================================================================== // Rôle : recalculer une fois par jour, à partir de l'historique Home Assistant, // les quatre profils horaires dont le bilan a besoin : // 1. consommation typique (p50) et prudente (p80), hors véhicule ; // 2. enveloppe de production démontrée (production maximale observée) ; // 3. taux de censure par heure (part des heures où la batterie était pleine). // // Tous sont publiés en CSV compact de 24 valeurs dans des helpers input_text // (moins de 255 caractères), relus à chaque cycle par le bilan. // // « La consommation se lit, elle ne se devine pas. » Contrairement à la // production, la mesure de consommation n'est PAS censurée : elle ne dépend // d'aucune capacité d'absorption. C'est donc la seule des deux grandeurs qu'on // peut profiler directement. // // Consommation maison = énergie livrée aux charges AC (depuis l'onduleur ET en // transit depuis le réseau) MOINS l'énergie prise par la borne de recharge. // On retire l'EVCS parce que la voiture est une charge pilotable : elle ne doit // pas polluer le profil de la charge subie, sinon le système se croit // structurellement plus gourmand qu'il ne l'est les jours où la voiture charge. // // Méthode : ce n'est PAS une moyenne glissante toutes heures confondues. Pour // chaque heure de la journée on constitue la distribution des valeurs observées // à cette heure-là sur tous les jours de l'historique, puis on en tire des // percentiles. Le p80 est la valeur prudente (« une journée sur cinq consomme // plus que ça »), le p50 la valeur typique. // // Entrée : réponse brute de /api/history/period (tableau de tableaux, le // premier point de chaque liste portant son entity_id), reçue soit // décodée, soit sous forme de chaîne dans `data` selon le réglage du // nœud HTTP. Les tests peuvent fournir directement `series`. // ===================================================================== function num(v, fallback = NaN) { const n = Number.parseFloat(v); return Number.isFinite(n) ? n : fallback; } function round2(v) { return Number.isFinite(v) ? Math.round(v * 100) / 100 : null; } const raw = $input.first().json; const CFG = { TIMEZONE: 'Europe/Paris', // Correspondance entité → rôle. Les seules entités que ce nœud sait lire. ENTITIES: { 'sensor.airtria_mpii_v1_energy_from_inverter_to_ac_out': 'inverter_to_ac_out', 'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_ac_out': 'ac_in_to_ac_out', 'sensor.evcs_hq2239dnt7p_total_energy': 'evcs_total', 'sensor.gx_device_pv_energy': 'pv_energy', 'sensor.victron_battery_soc': 'soc', }, // SOC à partir duquel l'heure est comptée comme censurée : la batterie n'est // plus un puits, la production mesurée ne dit plus rien de la ressource. CENSORSHIP_SOC_PCT: 90, // Un delta horaire au-delà de cette valeur n'est pas une consommation // plausible sur cette installation : c'est un artefact de compteur. MAX_PLAUSIBLE_HOURLY_KWH: 50, // En dessous de ce nombre de jours observés pour une heure donnée, le // percentile n'a pas de sens : on le signale au lieu de le publier en silence. MIN_DAYS_FOR_PERCENTILE: 5, }; // --------------------------------------------------------------------- // 1. Regroupement horaire d'une série de compteur cumulé. // On garde la DERNIÈRE valeur de chaque heure : le delta entre deux heures // consécutives est l'énergie consommée pendant l'heure écoulée. // --------------------------------------------------------------------- function hourBucketMs(ms) { return Math.floor(ms / 3600000) * 3600000; } function bucketHourly(series) { const buckets = new Map(); for (const point of series || []) { const ms = Date.parse(point.ts ?? point.last_changed ?? point.last_updated); const value = num(point.state ?? point.value); if (!Number.isFinite(ms) || !Number.isFinite(value)) continue; const h = hourBucketMs(ms); const prev = buckets.get(h); if (!prev || ms >= prev.ms) buckets.set(h, { ms, value }); } return buckets; } // 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; } if (hours[i] - hours[i - 1] > 3600000 && d > 0) spannedGaps++; deltas.set(hours[i], d); } return { deltas, resets, spannedGaps }; } // --------------------------------------------------------------------- // 0. Normalisation de l'entrée : réponse /api/history OU séries déjà prêtes. // --------------------------------------------------------------------- function decode(rawInput) { if (rawInput && rawInput.series) return rawInput.series; let history = rawInput && rawInput.history ? rawInput.history : rawInput; if (history && typeof history.data === 'string') { try { history = JSON.parse(history.data); } catch { history = null; } } else if (history && history.data) { history = history.data; } if (!Array.isArray(history)) return {}; const series = {}; for (const list of history) { if (!Array.isArray(list) || !list.length) continue; const role = CFG.ENTITIES[list[0].entity_id]; if (!role) continue; // /api/history en mode minimal ne répète pas entity_id : chaque point porte // seulement `state` et `last_changed`. series[role] = list.map(p => ({ ts: p.last_changed || p.last_updated, state: p.state })); } return series; } const series = decode(raw); const input = { series, nowMs: raw && raw.nowMs }; const fromInverter = deltaSeries(bucketHourly(series.inverter_to_ac_out)); const fromGrid = deltaSeries(bucketHourly(series.ac_in_to_ac_out)); const fromEvcs = deltaSeries(bucketHourly(series.evcs_total)); const fromPv = deltaSeries(bucketHourly(series.pv_energy)); // SOC : moyenne des relevés de chaque heure. Sert uniquement à savoir si // l'heure était censurée, pas à un calcul d'énergie. function hourlyMean(seriesPoints) { const acc = new Map(); for (const point of seriesPoints || []) { const ms = Date.parse(point.ts ?? point.last_changed ?? point.last_updated); const value = num(point.state ?? point.value); if (!Number.isFinite(ms) || !Number.isFinite(value)) continue; const h = hourBucketMs(ms); const prev = acc.get(h) || { sum: 0, n: 0 }; prev.sum += value; prev.n += 1; acc.set(h, prev); } const out = new Map(); for (const [h, v] of acc) out.set(h, v.sum / v.n); return out; } const socHourly = hourlyMean(series.soc); // --------------------------------------------------------------------- // 2. Consommation maison par heure, hors véhicule. // --------------------------------------------------------------------- const hourOfDay = (ms) => Number.parseInt( new Intl.DateTimeFormat('fr-FR', { timeZone: CFG.TIMEZONE, hour: '2-digit', hour12: false, }).format(new Date(ms)), 10 ); const byHourOfDay = Array.from({ length: 24 }, () => []); const days = new Set(); let evcsHours = 0; for (const [ms, inverterKwh] of fromInverter.deltas) { const gridKwh = fromGrid.deltas.get(ms) ?? 0; const evcsKwh = fromEvcs.deltas.get(ms) ?? 0; if (evcsKwh > 0) evcsHours++; const houseKwh = Math.max(0, inverterKwh + gridKwh - evcsKwh); byHourOfDay[hourOfDay(ms)].push(houseKwh); days.add(new Date(ms).toISOString().slice(0, 10)); } // --------------------------------------------------------------------- // 2bis. Enveloppe de production et taux de censure. // // L'enveloppe est le maximum horaire jamais observé. C'est une borne sûre : // une heure censurée ne peut pas produire un maximum, donc le maximum vient // forcément d'une heure où le système avait de la place. // --------------------------------------------------------------------- const envelope = Array.from({ length: 24 }, () => 0); const censusTotal = Array.from({ length: 24 }, () => 0); const censusCensored = Array.from({ length: 24 }, () => 0); for (const [ms, pvKwh] of fromPv.deltas) { const h = hourOfDay(ms); if (pvKwh > envelope[h]) envelope[h] = pvKwh; const soc = socHourly.get(ms); if (soc === undefined) continue; censusTotal[h]++; if (soc >= CFG.CENSORSHIP_SOC_PCT) censusCensored[h]++; } // Sans échantillon pour une heure, on suppose la pire hypothèse (censurée) : // le bilan signalera une forme minorée plutôt que de la croire fiable. const censorshipRate = censusTotal.map((n, h) => (n ? censusCensored[h] / n : 1)); // --------------------------------------------------------------------- // 3. Percentiles par heure de la journée. // --------------------------------------------------------------------- function percentile(values, p) { if (!values.length) return null; const sorted = [...values].sort((a, b) => a - b); return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]; } const profile = byHourOfDay.map((values, hour) => ({ hour, n: values.length, p20: round2(percentile(values, 0.2)), p50: round2(percentile(values, 0.5)), p80: round2(percentile(values, 0.8)), reliable: values.length >= CFG.MIN_DAYS_FOR_PERCENTILE, })); const complete = profile.every(h => h.reliable); // CSV compact pour un helper input_text (limite 255 caractères) : 24 valeurs // au format 0.00, soit ~120 caractères. Le p50 et le p80 vont dans deux helpers // distincts, le choix du percentile appartient au consommateur. const csv = (key) => profile.map(h => (h[key] == null ? '' : h[key].toFixed(2))).join(','); const dailyTotal = (key) => round2(profile.reduce((sum, h) => sum + (h[key] ?? 0), 0)); return [{ json: { generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(), days: days.size, profile, complete, csv: { p50: csv('p50'), p80: csv('p80'), envelope: envelope.map(v => v.toFixed(2)).join(','), censorshipRate: censorshipRate.map(v => v.toFixed(2)).join(','), }, production: { envelopeKwh: envelope.map(round2), censorshipRate: censorshipRate.map(v => Math.round(v * 100) / 100), demonstratedDailyKwh: round2(envelope.reduce((a, b) => a + b, 0)), censoredDayHours: censorshipRate.filter((r, h) => envelope[h] > 0.2 && r > 0.25).length, }, totals: { typicalDailyKwh: dailyTotal('p50'), prudentDailyKwh: dailyTotal('p80'), }, quality: { hoursUsed: fromInverter.deltas.size, pvHoursUsed: fromPv.deltas.size, 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, }, }];