Chantier etape 1 : detection de la censure de la production PV

Sans injection reseau, PV_mesuree = min(ressource, conso + absorption batterie).
La mesure est donc censuree a droite des que la batterie est pleine ou que le
reseau alimente AC-IN. Mesure sur l'historique du 28/07 au 24/08 : 48 % des
heures de jour sont censurees, 77 a 96 % a partir de 13 h. Un profil de
production bati sur la mesure brute mesure en realite sa propre consommation.

src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl : 26 champs, dont tous les
signaux qu'aucun workflow ne lisait jusqu'ici — etats des trois MPPT, etat
VE.Bus, alarme de surcharge onduleur, ESS BatteryLife, etat du bus DC, statut
natif de la borne, prevision horaire, elevation du soleil.

src/energy-balance/CENSORSHIP.js : qualifie chaque echantillon (censure et ses
motifs, exploitabilite) et verifie en continu l'identite du bus DC
PV + VE.Bus = batterie, qui boucle a 95 W pres — un capteur qui derive devient
detectable. Ne decide rien.

tests/energy-balance/ : 10 scenarios, dont un snapshot reel du 24/08 14:29
(soleil 52 degres, prevision 3462 W, production bridee a 1159 W, SOC 94 %)
correctement classe censure.

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:31:24 +02:00
parent 1eb1c7a9a4
commit 7cdb45ecf9
6 changed files with 497 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
{
"acin1_available": "0",
"battery_current_a": "2.299999952316284",
"battery_voltage_v": "51.189998626708984",
"dc_battery_state": "charging",
"ess_batterylife_state": "self_consumption_soc_exceeds_85",
"evcs_ac_power_w": "0.0",
"evcs_native_status": "waiting_for_start",
"forecast_next_hour_w": "3462",
"forecast_today_kwh": "30.241",
"forecast_today_remaining_kwh": "15.917",
"forecast_tomorrow_kwh": "29.076",
"grid_power_l1_w": "0",
"grid_switch": "off",
"house_ac_power_w": "929.0",
"mppt_1_state": "external_control",
"mppt_2_state": "external_control",
"mppt_3_state": "external_control",
"multiplus_overload_alarm": "no_alarm",
"multiplus_state": "inverting",
"pv_energy_total_kwh": "0.345",
"pv_power_w": "1159.3799828340113",
"soc_percent": "94.0",
"sun_elevation": 52.14,
"system_state": "inverting",
"ts": "2026-08-24T14:29:07.705982+02:00",
"vebus_ac_out_l1_power_w": "929",
"vebus_dc_power_w": "-933.0"
}

View File

@@ -0,0 +1,72 @@
#!/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).
//
// Usage : node tests/energy-balance/run.mjs [--print] [--only=<motif>]
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';
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,
};
vm.createContext(sandbox);
const out = vm.runInContext(`(function(){ ${CODE} })()`, sandbox, { timeout: 5000 });
return out[0].json;
}
function summarize(o) {
return {
pvCensored: o.censorship.pvCensored,
reasons: o.censorship.reasons.join('|'),
sampleExploitable: o.censorship.sampleExploitable,
healthOk: o.health.ok,
alerts: o.alerts.join('|'),
};
}
let failed = 0;
for (const sc of scenarios) {
if (ONLY && !sc.id.includes(ONLY)) continue;
const got = summarize(runNode(sc.snapshot));
if (PRINT) {
console.log(`─── ${sc.id} | ${sc.label}`);
console.log(' ' + JSON.stringify(got));
continue;
}
const diffs = [];
for (const [k, want] of Object.entries(sc.expect)) {
if (JSON.stringify(got[k]) !== JSON.stringify(want)) {
diffs.push(` ${k}: attendu ${JSON.stringify(want)}, obtenu ${JSON.stringify(got[k])}`);
}
}
if (diffs.length) {
failed++;
console.log(`❌ ${sc.id} | ${sc.label}`);
diffs.forEach(d => console.log(d));
} else {
console.log(`✅ ${sc.id}`);
}
}
if (PRINT) process.exit(0);
console.log('');
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`);

View File

@@ -0,0 +1,140 @@
// Scénarios du nœud CENSORSHIP.
// `fixture-live.json` est un snapshot RÉEL capturé le 24/08/2026 à 14:29 :
// soleil à 52°, prévision 3462 W pour l'heure suivante, production mesurée
// 1159 W, SOC 94 %, batterie à +2,3 A. C'est exactement le cas de censure —
// il ne manquait pas de soleil, il manquait un preneur.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const live = JSON.parse(fs.readFileSync(path.join(HERE, 'fixture-live.json'), 'utf8'));
// Base non censurée : plein soleil, batterie à mi-charge qui encaisse, hors réseau.
const libre = {
...live,
soc_percent: '62',
battery_current_a: '95',
battery_voltage_v: '51.2',
pv_power_w: '7400',
house_ac_power_w: '2500',
evcs_ac_power_w: '0',
vebus_dc_power_w: '-2537',
multiplus_state: 'bulk',
dc_battery_state: 'charging',
ess_batterylife_state: 'self_consumption',
acin1_available: '0',
grid_switch: 'off',
mppt_1_state: 'external_control',
mppt_2_state: 'external_control',
mppt_3_state: 'external_control',
sun_elevation: 52.1,
};
export const scenarios = [
{
id: 'live-censure-batterie-pleine',
label: 'Snapshot réel 24/08 14:29 — soleil 52°, SOC 94 %, production bridée à 1159 W',
snapshot: live,
expect: {
pvCensored: true,
reasons: 'batterie_pleine_sans_puits',
sampleExploitable: false,
healthOk: true,
},
},
{
id: 'echantillon-exploitable',
label: 'Plein soleil, batterie à 62 % qui encaisse 95 A, hors réseau → mesure exploitable',
snapshot: libre,
expect: {
pvCensored: false,
reasons: '',
sampleExploitable: true,
healthOk: true,
},
},
{
id: 'censure-reseau-present',
label: 'AC-IN présent : le réseau alimente les charges, le MPPT est bridé',
snapshot: { ...libre, acin1_available: '1' },
expect: {
pvCensored: true,
reasons: 'reseau_present',
sampleExploitable: false,
},
},
{
id: 'censure-fin-de-charge',
label: 'VE.Bus en absorption : le courant de charge est volontairement réduit',
snapshot: { ...libre, multiplus_state: 'absorption' },
expect: {
pvCensored: true,
reasons: 'fin_de_charge_absorption',
sampleExploitable: false,
},
},
{
id: 'censure-mppt-arretes',
label: 'Deux trackers à l\'arrêt en plein jour : écrêtage direct du MPPT',
snapshot: { ...libre, mppt_1_state: 'off', mppt_3_state: 'off' },
expect: {
pvCensored: true,
reasons: 'mppt_arretes_2_sur_3',
sampleExploitable: false,
},
},
{
id: 'censure-bus-dc-au-repos',
label: 'Bus DC au repos alors que la production tourne : plus aucun puits',
snapshot: { ...libre, dc_battery_state: 'idle', battery_current_a: '0.2' },
expect: {
pvCensored: true,
reasons: 'bus_dc_au_repos_en_plein_jour',
sampleExploitable: false,
},
},
{
id: 'nuit-pas-de-censure-mais-inexploitable',
label: 'Nuit : rien n\'est censuré, mais la mesure ne dit rien de la ressource',
snapshot: {
...libre,
sun_elevation: -12,
pv_power_w: '0',
battery_current_a: '-14',
vebus_dc_power_w: '-716',
dc_battery_state: 'discharging',
multiplus_state: 'inverting',
},
expect: {
pvCensored: false,
sampleExploitable: false,
},
},
{
id: 'alerte-surcharge-multiplus',
label: 'Alarme de surcharge onduleur — jusqu\'ici jamais lue par aucun workflow',
snapshot: { ...libre, multiplus_overload_alarm: 'warning' },
expect: {
alerts: 'multiplus_overload_warning',
},
},
{
id: 'alerte-batterylife-limite-soc',
label: 'ESS BatteryLife impose sa propre limite de SOC, à l\'insu du contrôleur',
snapshot: { ...libre, ess_batterylife_state: 'soc_below_battery_life_dynamic_soc_limit' },
expect: {
alerts: 'batterylife_limite_le_soc',
},
},
{
id: 'sante-capteur-incoherent',
label: 'Identité du bus DC rompue (PV + VE.Bus != batterie) → un capteur ment',
snapshot: { ...libre, vebus_dc_power_w: '-200' },
expect: {
healthOk: false,
alerts: 'incoherence_mesures_bus_dc',
},
},
];