Chantier etape 2 : profil horaire de consommation mesure
CONSUMPTION_PROFILE.js : compteurs d'energie cumules -> deltas horaires -> distribution par heure de la journee -> percentiles. La consommation du vehicule est retiree : c'est une charge pilotable, elle ne doit pas polluer le profil de la charge subie. Les remises a zero de compteur et les deltas implausibles sont jetes, jamais rattrapes par une valeur inventee, et une heure sans assez de jours observes est marquee non fiable au lieu d'etre publiee en silence. Valide sur 28 jours reels (639 heures, 0 reset) : 31,9 kWh/jour typique hors vehicule, 42,8 kWh au p80. Le profil tient dans un helper input_text (119 caracteres sur 255). Verification prealable de la methode : seulement 7,8 % des heures de deficit sont censurees. Censure et deficit sont physiquement presque exclusifs, donc la grandeur qui pilote la decision EDF est mesuree sur des donnees propres. Suite tests/energy-balance/ etendue aux deux noeuds : 14 scenarios. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
165
src/energy-balance/CONSUMPTION_PROFILE.js
Normal file
165
src/energy-balance/CONSUMPTION_PROFILE.js
Normal file
@@ -0,0 +1,165 @@
|
||||
// EVCS Energy Balance — CONSUMPTION_PROFILE
|
||||
// =====================================================================
|
||||
// Rôle : produire le profil horaire de consommation de la maison, MESURÉ.
|
||||
// Une valeur par heure de la journée, calculée sur l'historique disponible.
|
||||
//
|
||||
// « 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 : séries de compteurs d'énergie cumulés, au format /api/history de HA
|
||||
// ({ ts, state }), non nécessairement réguliers.
|
||||
// Sortie : profile[24] + un CSV compact destiné à un helper input_text (255 car.)
|
||||
// =====================================================================
|
||||
|
||||
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 input = $input.first().json;
|
||||
|
||||
const CFG = {
|
||||
TIMEZONE: 'Europe/Paris',
|
||||
// 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 };
|
||||
}
|
||||
|
||||
const fromInverter = deltaSeries(bucketHourly(input.series?.inverter_to_ac_out));
|
||||
const fromGrid = deltaSeries(bucketHourly(input.series?.ac_in_to_ac_out));
|
||||
const fromEvcs = deltaSeries(bucketHourly(input.series?.evcs_total));
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 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));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 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'),
|
||||
},
|
||||
totals: {
|
||||
typicalDailyKwh: dailyTotal('p50'),
|
||||
prudentDailyKwh: dailyTotal('p80'),
|
||||
},
|
||||
quality: {
|
||||
hoursUsed: fromInverter.deltas.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