From 7cdb45ecf963608aa688a049b98df0e84fc61066 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Mon, 24 Aug 2026 14:31:24 +0200 Subject: [PATCH] Chantier etape 1 : detection de la censure de la production PV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ --- docs/CHANTIER_BILAN_ENERGETIQUE.md | 14 ++ src/energy-balance/CENSORSHIP.js | 205 ++++++++++++++++++ .../HA_SNAPSHOT_TEMPLATE.json.tpl | 37 ++++ tests/energy-balance/fixture-live.json | 29 +++ tests/energy-balance/run.mjs | 72 ++++++ tests/energy-balance/scenarios.mjs | 140 ++++++++++++ 6 files changed, 497 insertions(+) create mode 100644 src/energy-balance/CENSORSHIP.js create mode 100644 src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl create mode 100644 tests/energy-balance/fixture-live.json create mode 100644 tests/energy-balance/run.mjs create mode 100644 tests/energy-balance/scenarios.mjs diff --git a/docs/CHANTIER_BILAN_ENERGETIQUE.md b/docs/CHANTIER_BILAN_ENERGETIQUE.md index beaa4d6..76a33da 100644 --- a/docs/CHANTIER_BILAN_ENERGETIQUE.md +++ b/docs/CHANTIER_BILAN_ENERGETIQUE.md @@ -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 · 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 - [ ] **Doublon `unique_id: victron_vebus_dc_power`** — déclaré deux fois, avec diff --git a/src/energy-balance/CENSORSHIP.js b/src/energy-balance/CENSORSHIP.js new file mode 100644 index 0000000..5456220 --- /dev/null +++ b/src/energy-balance/CENSORSHIP.js @@ -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, + }, +}]; diff --git a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl new file mode 100644 index 0000000..aa52820 --- /dev/null +++ b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -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 }} diff --git a/tests/energy-balance/fixture-live.json b/tests/energy-balance/fixture-live.json new file mode 100644 index 0000000..1aff784 --- /dev/null +++ b/tests/energy-balance/fixture-live.json @@ -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" +} diff --git a/tests/energy-balance/run.mjs b/tests/energy-balance/run.mjs new file mode 100644 index 0000000..f30edc0 --- /dev/null +++ b/tests/energy-balance/run.mjs @@ -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=] + +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`); diff --git a/tests/energy-balance/scenarios.mjs b/tests/energy-balance/scenarios.mjs new file mode 100644 index 0000000..e1cfc62 --- /dev/null +++ b/tests/energy-balance/scenarios.mjs @@ -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', + }, + }, +];