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

@@ -34,6 +34,20 @@ Heure de croisement PV/conso · énergie à couvrir d'ici là · niveau de batte
requis (remplace `grid_soc_off` fixe) · marge disponible pour le véhicule · requis (remplace `grid_soc_off` fixe) · marge disponible pour le véhicule ·
drapeau « production actuellement bridée » · drapeau de santé des mesures. drapeau « production actuellement bridée » · drapeau de santé des mesures.
## Avancement
- [x] **Étape 1 — détection de la censure** (24/08). `src/energy-balance/` :
snapshot HA de 26 champs incluant les signaux jamais lus (états MPPT, état
VE.Bus, alarme de surcharge, ESS BatteryLife, état du bus DC, statut natif de
la borne, prévision horaire) et nœud `CENSORSHIP.js` qui qualifie chaque
é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é).
- [ ] Étape 3 — estimateur de ressource PV = prévision recalée sur les heures
exploitables de l'étape 1.
- [ ] Étape 4 — bilan et ses sorties.
## Backlog ## Backlog
- [ ] **Doublon `unique_id: victron_vebus_dc_power`** — déclaré deux fois, avec - [ ] **Doublon `unique_id: victron_vebus_dc_power`** — déclaré deux fois, avec

View File

@@ -0,0 +1,205 @@
// EVCS Energy Balance — CENSORSHIP
// =====================================================================
// Rôle : dire si la production PV mesurée à cet instant représente la
// RESSOURCE solaire disponible, ou si elle est CENSURÉE.
//
// L'installation est un ESS Victron sans injection réseau. Le MPPT ne produit
// que ce que le système peut absorber immédiatement :
//
// PV_mesurée = min( ressource réelle , conso maison + absorption batterie )
//
// Dès que la batterie est pleine ou que le réseau alimente AC-IN, la mesure est
// une observation censurée à droite : elle dit « on n'avait pas besoin de plus »,
// pas « il n'y avait pas plus ». Sur l'historique du 28/07 au 24/08, 48 % des
// heures de jour sont censurées — 77 à 96 % à partir de 13 h. Bâtir un profil de
// production sur la mesure brute revient donc à mesurer sa propre consommation.
//
// Ce nœud ne décide RIEN. Il qualifie l'échantillon, pour que l'estimation de
// ressource ne soit recalée que sur les heures exploitables.
//
// Sortie : censorship { pvCensored, reasons[], sampleExploitable, ... }
// health { residualW, ok } — contrôle de vraisemblance du bus DC.
// =====================================================================
function num(v, fallback = NaN) {
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : fallback;
}
function low(v) {
return (v ?? '').toString().trim().toLowerCase();
}
function round1(v) {
return Number.isFinite(v) ? Math.round(v * 10) / 10 : null;
}
const input = $input.first().json;
const CFG = {
// Le soleil doit être assez haut pour qu'une mesure de production ait un sens.
SUN_ELEVATION_MIN_DEG: 10,
// En dessous, on est dans le bruit (aube, crépuscule, couverture totale).
PV_MEANINGFUL_MIN_W: 200,
// SOC à partir duquel la batterie ne peut plus servir de puits significatif.
SOC_NEAR_FULL_PCT: 90,
// Puissance de charge batterie en dessous de laquelle, batterie quasi pleine,
// on considère qu'il n'y a plus de preneur pour le surplus.
BATTERY_SINK_MIN_W: 500,
// États VE.Bus de fin de charge : le courant est volontairement réduit.
MULTIPLUS_TAPER_STATES: ['absorption', 'float', 'storage'],
// Tolérance du contrôle de vraisemblance PV + VE.Bus = batterie.
HEALTH_ABS_TOLERANCE_W: 150,
HEALTH_REL_TOLERANCE: 0.15,
};
const pvPowerW = num(input.pv_power_w, 0);
const housePowerW = num(input.house_ac_power_w, 0);
const evcsPowerW = num(input.evcs_ac_power_w, 0);
const socPct = num(input.soc_percent, NaN);
const batteryCurrentA = num(input.battery_current_a, NaN);
const batteryVoltageV = num(input.battery_voltage_v, NaN);
const vebusDcPowerW = num(input.vebus_dc_power_w, NaN);
const sunElevationDeg = num(input.sun_elevation, NaN);
const forecastNextHourW = num(input.forecast_next_hour_w, NaN);
const batteryPowerW =
Number.isFinite(batteryCurrentA) && Number.isFinite(batteryVoltageV)
? batteryCurrentA * batteryVoltageV
: NaN;
const multiplusState = low(input.multiplus_state);
const systemState = low(input.system_state);
const essBatteryLifeState = low(input.ess_batterylife_state);
const dcBatteryState = low(input.dc_battery_state);
const overloadAlarm = low(input.multiplus_overload_alarm);
const evcsNativeStatus = low(input.evcs_native_status);
const mpptStates = [input.mppt_1_state, input.mppt_2_state, input.mppt_3_state]
.map(low)
.filter(s => s !== '' && s !== 'unavailable' && s !== 'unknown');
// AC-IN physiquement présent : c'est lui qui compte, pas la position du switch
// (le switch peut être ON alors que le disjoncteur est coupé — incident 08/07).
const gridPresent =
num(input.acin1_available, 0) === 1 || low(input.grid_switch) === 'on';
const sunUp =
!Number.isFinite(sunElevationDeg) || sunElevationDeg >= CFG.SUN_ELEVATION_MIN_DEG;
// =====================================================================
// CENSURE — chaque motif s'appuie sur un signal réellement lu, pas sur un seuil
// supposé. Ils sont cumulables : on garde la liste complète pour le diagnostic.
// =====================================================================
const reasons = [];
if (gridPresent) {
reasons.push('reseau_present');
}
if (CFG.MULTIPLUS_TAPER_STATES.includes(multiplusState)) {
reasons.push(`fin_de_charge_${multiplusState}`);
}
const batteryNearFull =
Number.isFinite(socPct) && socPct >= CFG.SOC_NEAR_FULL_PCT;
const batterySinkWeak =
!Number.isFinite(batteryPowerW) || batteryPowerW < CFG.BATTERY_SINK_MIN_W;
if (sunUp && batteryNearFull && batterySinkWeak) {
reasons.push('batterie_pleine_sans_puits');
}
if (sunUp && dcBatteryState === 'idle' && pvPowerW > CFG.PV_MEANINGFUL_MIN_W) {
reasons.push('bus_dc_au_repos_en_plein_jour');
}
// Un tracker à l'arrêt en plein jour n'est pas une panne : c'est le MPPT qui
// coupe faute de preneur. C'est le signal le plus direct d'un écrêtage.
const mpptOffCount = mpptStates.filter(s => s === 'off').length;
if (sunUp && mpptOffCount > 0 && mpptStates.length > 0) {
reasons.push(`mppt_arretes_${mpptOffCount}_sur_${mpptStates.length}`);
}
const pvCensored = reasons.length > 0;
// Un échantillon n'est exploitable pour estimer la ressource que s'il n'est pas
// censuré ET qu'il y a assez de soleil pour que la mesure signifie quelque chose.
const sampleExploitable =
!pvCensored && sunUp && pvPowerW >= CFG.PV_MEANINGFUL_MIN_W;
// Indication (non un fait) de ce qu'on laisse passer : la prévision porte sur
// l'heure suivante, pas sur l'instant présent — à ne pas cumuler comme un
// compteur d'énergie perdue.
const shortfallHintW =
pvCensored && Number.isFinite(forecastNextHourW)
? Math.max(0, forecastNextHourW - pvPowerW)
: 0;
// =====================================================================
// SANTÉ DES MESURES — identité du bus DC : PV + VE.Bus = batterie.
// Vérifiée à 95 W près le 24/08/2026. Le jour où elle diverge, un capteur ment.
// =====================================================================
const healthMeasurable =
Number.isFinite(pvPowerW) && Number.isFinite(vebusDcPowerW) && Number.isFinite(batteryPowerW);
const residualW = healthMeasurable ? pvPowerW + vebusDcPowerW - batteryPowerW : NaN;
const healthTolerance = Math.max(
CFG.HEALTH_ABS_TOLERANCE_W,
CFG.HEALTH_REL_TOLERANCE * Math.abs(batteryPowerW || 0)
);
const healthOk = healthMeasurable ? Math.abs(residualW) <= healthTolerance : null;
// =====================================================================
// ALERTES SYSTÈME — jusqu'ici jamais lues par aucun workflow.
// =====================================================================
const alerts = [];
if (overloadAlarm && overloadAlarm !== 'no_alarm') alerts.push(`multiplus_overload_${overloadAlarm}`);
if (essBatteryLifeState.includes('soc_below')) alerts.push('batterylife_limite_le_soc');
if (healthOk === false) alerts.push('incoherence_mesures_bus_dc');
return [{
json: {
ts: input.ts,
measured: {
pvPowerW: round1(pvPowerW),
housePowerW: round1(housePowerW),
houseWithoutEvcsW: round1(Math.max(0, housePowerW - evcsPowerW)),
evcsPowerW: round1(evcsPowerW),
socPct: round1(socPct),
batteryPowerW: round1(batteryPowerW),
vebusDcPowerW: round1(vebusDcPowerW),
sunElevationDeg: round1(sunElevationDeg),
forecastNextHourW: Number.isFinite(forecastNextHourW) ? forecastNextHourW : null,
},
states: {
multiplusState,
systemState,
essBatteryLifeState,
dcBatteryState,
mpptStates,
evcsNativeStatus,
gridPresent,
},
censorship: {
pvCensored,
reasons,
sampleExploitable,
shortfallHintW: round1(shortfallHintW),
sunUp,
},
health: {
measurable: healthMeasurable,
residualW: round1(residualW),
toleranceW: round1(healthTolerance),
ok: healthOk,
},
alerts,
cfg: CFG,
},
}];

View File

@@ -0,0 +1,37 @@
{{ {
"ts": now().isoformat(),
"pv_power_w": states("sensor.victron_pv_power_system"),
"house_ac_power_w": states("sensor.victron_consumption_power_l1"),
"vebus_ac_out_l1_power_w": states("sensor.victron_ve_bus_ac_out_l1_power"),
"evcs_ac_power_w": states("sensor.victron_evcs_ac_power"),
"soc_percent": states("sensor.victron_battery_soc"),
"battery_current_a": states("sensor.victron_battery_current"),
"battery_voltage_v": states("sensor.victron_battery_voltage"),
"vebus_dc_power_w": states("sensor.victron_ve_bus_dc_power_system"),
"grid_switch": states("switch.edf_general_ok_switch"),
"acin1_available": states("sensor.victron_ac_in1_available"),
"grid_power_l1_w": states("sensor.victron_grid_power_l1"),
"multiplus_state": states("sensor.airtria_mpii_v1_state"),
"multiplus_overload_alarm": states("sensor.airtria_mpii_v1_overload_alarm"),
"system_state": states("sensor.gx_device_system_state"),
"ess_batterylife_state": states("sensor.gx_device_ess_batterylife_state"),
"dc_battery_state": states("sensor.gx_device_dc_battery_state"),
"mppt_1_state": states("sensor.airtria_mppt1_state"),
"mppt_2_state": states("sensor.mppt_450_200_hq2449yr9pt_state"),
"mppt_3_state": states("sensor.v1_state"),
"evcs_native_status": states("sensor.evcs_hq2239dnt7p_status"),
"forecast_today_remaining_kwh": states("sensor.energy_production_today_remaining"),
"forecast_today_kwh": states("sensor.energy_production_today"),
"forecast_tomorrow_kwh": states("sensor.energy_production_tomorrow"),
"forecast_next_hour_w": states("sensor.power_production_next_hour"),
"sun_elevation": state_attr("sun.sun", "elevation")
} | tojson }}

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