Chantier etapes 5b et 5c : recalcul quotidien des profils et fenetre de deficit suivante
5c — BALANCE ne repondait qu'a "combien jusqu'a ce que le solaire prenne le relais". A 15 h la reponse est zero : exacte mais inutile, le deficit qui approche etant celui de la nuit. Ajout de la prochaine traversee deficitaire (heure de debut, energie, SOC a atteindre avant). Ce SOC n'est PAS ecrete a 100 % : au-dela, meme une batterie pleine ne passe pas, et le manque est publie comme storage_shortfall_kwh — c'est le chiffre du dimensionnement. 5b — Workflow "EVCS Energy Profiles Daily V1" (N6UaB4ZozsbRPrFb), actif, 03:30 : lit /api/history/period sur 30 jours glissants et republie les quatre CSV (conso p50 et p80, enveloppe de production, taux de censure). CONSUMPTION_PROFILE devient DAILY_PROFILES et sait desormais decoder la reponse brute de l'API en plus des series deja pretes. Piege trouve : sans end_time, /api/history/period ne renvoie qu'UNE journee, silencieusement. Mes premieres mesures "sur 3 jours" n'en couvraient qu'une. Validation croisee : profils calcules depuis l'API REST (etats bruts, 11 j) contre profils calcules depuis les statistiques long terme (28 j) — 0,20 kWh/h d'ecart absolu moyen, par deux chemins de donnees independants. BUILD_HA_CALLS_PROFILES refuse d'ecraser les profils en place si l'historique ne couvre pas les 24 heures. Meme garantie de perimetre que le bilan. Suite a 27 scenarios. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
268
src/energy-balance/DAILY_PROFILES.js
Normal file
268
src/energy-balance/DAILY_PROFILES.js
Normal file
@@ -0,0 +1,268 @@
|
||||
// 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.
|
||||
function deltaSeries(buckets) {
|
||||
const hours = [...buckets.keys()].sort((a, b) => a - b);
|
||||
const deltas = new Map();
|
||||
let resets = 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);
|
||||
}
|
||||
return { deltas, resets };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 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,
|
||||
unreliableHours: profile.filter(h => !h.reliable).map(h => h.hour),
|
||||
},
|
||||
cfg: CFG,
|
||||
},
|
||||
}];
|
||||
Reference in New Issue
Block a user