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

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