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:
Tallagrand Thierry
2026-08-24 14:43:26 +02:00
parent 7cdb45ecf9
commit 4b7cc3ae18
5 changed files with 300 additions and 21 deletions

View File

@@ -43,7 +43,17 @@ drapeau « production actuellement bridée » · drapeau de santé des mesures.
échantillon. Suite `tests/energy-balance/` : 10 scénarios, dont un snapshot
réel du 24/08 14:29 correctement classé censuré. Mesure sur l'historique :
**48 % des heures de jour sont censurées, 77 à 96 % à partir de 13 h.**
- [ ] Étape 2 — profil de consommation mesuré (non censuré).
- [x] **Étape 2 — profil de consommation mesuré** (24/08).
`src/energy-balance/CONSUMPTION_PROFILE.js` : compteurs d'énergie cumulés →
deltas horaires → distribution par heure de la journée → percentiles.
Validé sur 28 jours réels (639 heures, aucun compteur remis à zéro) :
consommation typique 31,9 kWh/jour hors véhicule, 42,8 kWh au p80. Le profil
tient dans un helper `input_text` (119 caractères sur 255).
Vérification préalable de la méthode : **seulement 7,8 % des heures de déficit
sont censurées** — censure et déficit sont physiquement presque exclusifs
(une heure censurée est une heure où la batterie n'avait plus de place, donc
une heure de surplus). La grandeur qui pilote la décision EDF est donc
mesurée sur des données propres.
- [ ] Étape 3 — estimateur de ressource PV = prévision recalée sur les heures
exploitables de l'étape 1.
- [ ] Étape 4 — bilan et ses sorties.

View 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,
},
}];

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
// Suite de tests du nœud CENSORSHIP (chantier bilan énergétique).
// Exécute le VRAI code de src/energy-balance/CENSORSHIP.js dans une sandbox vm,
// avec le shim $input de n8n, sur des snapshots HA (réel + cas construits).
// Suite de tests du chantier bilan énergétique.
// Exécute le VRAI code des nœuds de src/energy-balance/ dans une sandbox vm,
// avec le shim $input de n8n, sur des snapshots HA (réels + cas construits).
//
// Usage : node tests/energy-balance/run.mjs [--print] [--only=<motif>]
@@ -9,41 +9,58 @@ import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
import { scenarios } from './scenarios.mjs';
import { scenarios as censorshipScenarios } from './scenarios.mjs';
import { profileScenarios } from './scenarios-profile.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const CODE = fs.readFileSync(path.join(ROOT, 'src/energy-balance/CENSORSHIP.js'), 'utf8');
const PRINT = process.argv.includes('--print');
const ONLY = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1];
function runNode(snapshot) {
const sandbox = {
$input: { first: () => ({ json: snapshot }) },
console,
};
const codeCache = new Map();
function runNode(nodeName, snapshot) {
if (!codeCache.has(nodeName)) {
codeCache.set(nodeName, fs.readFileSync(path.join(ROOT, `src/energy-balance/${nodeName}.js`), 'utf8'));
}
const sandbox = { $input: { first: () => ({ json: snapshot }) }, console };
vm.createContext(sandbox);
const out = vm.runInContext(`(function(){ ${CODE} })()`, sandbox, { timeout: 5000 });
return out[0].json;
return vm.runInContext(`(function(){ ${codeCache.get(nodeName)} })()`, sandbox, { timeout: 15000 })[0].json;
}
function summarize(o) {
return {
const SUMMARIZE = {
CENSORSHIP: o => ({
pvCensored: o.censorship.pvCensored,
reasons: o.censorship.reasons.join('|'),
sampleExploitable: o.censorship.sampleExploitable,
healthOk: o.health.ok,
alerts: o.alerts.join('|'),
}),
CONSUMPTION_PROFILE: o => ({
days: o.days,
complete: o.complete,
hours: o.profile.length,
countersReset: o.quality.countersReset,
unreliableHours: o.quality.unreliableHours.join(','),
csvFitsHelper: o.csv.p80.length <= 255 && o.csv.p50.length <= 255,
p50AtHour14: o.profile[14].p50,
p80AtHour14: o.profile[14].p80,
}),
};
}
const all = [
...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })),
...profileScenarios,
];
let failed = 0;
for (const sc of scenarios) {
let run = 0;
for (const sc of all) {
if (ONLY && !sc.id.includes(ONLY)) continue;
const got = summarize(runNode(sc.snapshot));
run++;
const got = SUMMARIZE[sc.node](runNode(sc.node, sc.snapshot));
if (PRINT) {
console.log(`─── ${sc.id} | ${sc.label}`);
console.log(`─── [${sc.node}] ${sc.id} | ${sc.label}`);
console.log(' ' + JSON.stringify(got));
continue;
}
@@ -69,4 +86,4 @@ if (failed) {
console.log(`${failed} ÉCHEC(S) — NE PAS POUSSER EN PRODUCTION`);
process.exit(1);
}
console.log(`ENERGY BALANCE OK — ${scenarios.length}/${scenarios.length} scénarios conformes`);
console.log(`ENERGY BALANCE OK — ${run}/${run} scénarios conformes`);

View File

@@ -0,0 +1,86 @@
// Scénarios du nœud CONSUMPTION_PROFILE.
// `fixture-history.json` = 28 jours réels (28/07 → 24/08/2026), un point horaire
// par compteur, au format renvoyé par /api/history.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const history = JSON.parse(fs.readFileSync(path.join(HERE, 'fixture-history.json'), 'utf8'));
const NOW = Date.parse('2026-08-24T14:00:00Z');
// Petit historique synthétique : 10 jours, conso maison constante de 1 kWh/h,
// et une charge de véhicule de 3 kWh à 14 h tous les jours.
function syntheticSeries({ days = 10, houseKwhPerHour = 1, evcsKwhAt14h = 3 } = {}) {
const inverter = [];
const evcs = [];
let cumInverter = 0;
let cumEvcs = 0;
const start = Date.parse('2026-08-01T00:00:00+02:00');
for (let h = 0; h < days * 24; h++) {
const ts = new Date(start + h * 3600000);
const isEvcsHour = ts.getUTCHours() === 12; // 14 h Paris en août
cumInverter += houseKwhPerHour + (isEvcsHour ? evcsKwhAt14h : 0);
cumEvcs += isEvcsHour ? evcsKwhAt14h : 0;
inverter.push({ ts: ts.toISOString(), state: String(cumInverter) });
evcs.push({ ts: ts.toISOString(), state: String(cumEvcs) });
}
return { inverter_to_ac_out: inverter, ac_in_to_ac_out: [], evcs_total: evcs };
}
export const profileScenarios = [
{
node: 'CONSUMPTION_PROFILE',
id: 'profil-historique-reel',
label: '28 jours réels : profil complet sur 24 heures, aucun compteur remis à zéro',
snapshot: { series: history.series, nowMs: NOW },
expect: {
days: 28,
complete: true,
hours: 24,
countersReset: 0,
unreliableHours: '',
csvFitsHelper: true,
},
},
{
node: 'CONSUMPTION_PROFILE',
id: 'evcs-retire-du-profil',
label: 'La charge du véhicule est retirée : le profil reste plat à 1 kWh/h, y compris à 14 h',
snapshot: { series: syntheticSeries(), nowMs: NOW },
expect: {
p50AtHour14: 1,
p80AtHour14: 1,
complete: true,
},
},
{
node: 'CONSUMPTION_PROFILE',
id: 'compteur-remis-a-zero',
label: 'Remise à zéro du compteur (redémarrage HA) : l\'échantillon est jeté, pas inventé',
snapshot: (() => {
const s = syntheticSeries();
// 5e jour à 10 h : le compteur repart de zéro.
const idx = 4 * 24 + 10;
for (let i = idx; i < s.inverter_to_ac_out.length; i++) {
s.inverter_to_ac_out[i].state = String(Number(s.inverter_to_ac_out[i].state) - 100);
}
return { series: s, nowMs: NOW };
})(),
expect: {
countersReset: 1,
complete: true,
},
},
{
node: 'CONSUMPTION_PROFILE',
id: 'historique-trop-court',
label: '3 jours seulement : les heures sont marquées non fiables plutôt que publiées en silence',
snapshot: { series: syntheticSeries({ days: 3 }), nowMs: NOW },
expect: {
complete: false,
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',
},
},
];