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,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 }}