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

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

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

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

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

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

297 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
},
}];