From 5a8e4afde07cb2219c240f1829fabf4a3a472df1 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Mon, 28 Sep 2026 01:39:54 +0200 Subject: [PATCH] Bilan : prevision solaire VRM (selecteur de source), les deux sources enregistrees Decision du proprietaire (28/09) : Forecast.Solar renvoie des totaux quasi constants (~26 kWh) pour 40-47 reels ; VRM suivait mieux le reel sur 10 jours (erreur 21 % contre 32 %, 14 % contre 36 % sur les 5 derniers). Le bilan lit VRM des maintenant ; les deux previsions sont enregistrees (colonne source, cle etendue) pour les comparer dans une semaine. - input_select.evcs_balance_forecast_source (vrm | forecast_solar) = retour arriere en un geste ; helpers evcs_balance_vrm_* au format du bilan - historisation : lecture VRM (jeton dans .env, jamais dans HA) toutes les 15 min, vrmVersCourbes (jour et heure de Paris), tests 69/69 - modele du bilan conditionnel ; bilan 50/50, deploye - evcs_prevision_contre_reel porte sur la source active Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z --- packages/evcs_prevision_vrm.yaml | 30 +++++++++ .../HA_SNAPSHOT_TEMPLATE.json.tpl | 9 +-- tests/diagnostics-agent/run.mjs | 24 ++++++- tools/diagnostics-agent.mjs | 45 ++++++++++++- tools/historise-evcs.mjs | 65 +++++++++++++++++-- workflows/evcs-energy-balance-v1.json | 2 +- 6 files changed, 162 insertions(+), 13 deletions(-) create mode 100755 packages/evcs_prevision_vrm.yaml diff --git a/packages/evcs_prevision_vrm.yaml b/packages/evcs_prevision_vrm.yaml new file mode 100755 index 0000000..2d572db --- /dev/null +++ b/packages/evcs_prevision_vrm.yaml @@ -0,0 +1,30 @@ +# Prevision solaire VRM pour le bilan energetique (28/09/2026). +# Forecast.Solar renvoyait depuis le 18/09 des totaux quasi constants (~26 kWh) +# pour 40-47 kWh reels ; VRM suivait mieux le reel sur 10 jours (erreur 21 % +# contre 32 %). Decision du proprietaire : basculer le bilan sur VRM tout de +# suite, enregistrer les DEUX previsions chaque jour, comparer au bout d'une +# semaine. Retour arriere = remettre le selecteur sur forecast_solar. +# Les champs VRM sont ecrits par le service d'historisation (le jeton VRM reste +# dans le .env du depot, jamais dans Home Assistant), au MEME format que ceux de +# Forecast.Solar : le bilan les lit indifferemment. +input_select: + evcs_balance_forecast_source: + name: "Bilan — source de la prévision solaire" + options: + - vrm + - forecast_solar + icon: mdi:weather-sunny-alert + +input_text: + evcs_balance_vrm_day0: + name: "Bilan VRM — date de la courbe de prévision" + max: 10 + evcs_balance_vrm_today: + name: "Bilan VRM — prévision horaire du jour (CSV 24 h, kWh)" + max: 255 + evcs_balance_vrm_tomorrow: + name: "Bilan VRM — prévision horaire de demain (CSV 24 h, kWh)" + max: 255 + evcs_balance_vrm_updated: + name: "Bilan VRM — prévision mise à jour" + max: 128 diff --git a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl index ba8016e..4c1d734 100644 --- a/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl +++ b/src/energy-balance/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -11,10 +11,11 @@ "envelope_csv": states("input_text.evcs_balance_envelope"), "censorship_rate_csv": states("input_text.evcs_balance_censorship_rate"), - "forecast_day0": states("input_text.evcs_balance_forecast_day0"), - "forecast_today_csv": states("input_text.evcs_balance_forecast_today"), - "forecast_tomorrow_csv": states("input_text.evcs_balance_forecast_tomorrow"), - "forecast_updated": states("input_text.evcs_balance_forecast_updated"), + "forecast_source": states("input_select.evcs_balance_forecast_source"), + "forecast_day0": states("input_text.evcs_balance_vrm_day0") if is_state("input_select.evcs_balance_forecast_source", "vrm") else states("input_text.evcs_balance_forecast_day0"), + "forecast_today_csv": states("input_text.evcs_balance_vrm_today") if is_state("input_select.evcs_balance_forecast_source", "vrm") else states("input_text.evcs_balance_forecast_today"), + "forecast_tomorrow_csv": states("input_text.evcs_balance_vrm_tomorrow") if is_state("input_select.evcs_balance_forecast_source", "vrm") else states("input_text.evcs_balance_forecast_tomorrow"), + "forecast_updated": states("input_text.evcs_balance_vrm_updated") if is_state("input_select.evcs_balance_forecast_source", "vrm") else states("input_text.evcs_balance_forecast_updated"), "grid_soc_off": states("input_number.victron_grid_soc_off"), "manual_hc_floor_percent": states("input_number.evcs_battery_manual_hc_floor"), diff --git a/tests/diagnostics-agent/run.mjs b/tests/diagnostics-agent/run.mjs index b874954..d3e7b24 100644 --- a/tests/diagnostics-agent/run.mjs +++ b/tests/diagnostics-agent/run.mjs @@ -2,7 +2,7 @@ // Les cas PV reprennent les gammes du banc OpenClaw (HANDOFF §77) : G3, G4, G5, G7. // node tests/diagnostics-agent/run.mjs import fs from 'node:fs'; -import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, COMPTEURS_IMPORT, COMPTEUR_EDF, MPPT_MODES, ORIGINE_PAR_SOURCE, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit } from '../../tools/diagnostics-agent.mjs'; +import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, COMPTEURS_IMPORT, COMPTEUR_EDF, MPPT_MODES, ORIGINE_PAR_SOURCE, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes } from '../../tools/diagnostics-agent.mjs'; const LIM = 'voltage_current_limited', ACT = 'mppt_active'; function st({ pv, conso, reseau, soc = 80, courant = 0, edf = 'off', modes }) { @@ -317,6 +317,28 @@ for (const [nom, r, test] of imp) { globalThis.__nConso = (globalThis.__nConso || 0) + pn.length; } +// --- Prevision VRM -> courbes du bilan (heure de Paris) --------------------- +{ + // 28/09 : 12:00 et 13:00 heure de Paris = 10:00 et 11:00 UTC ; 00:00 Paris = 22:00 UTC la veille. + const paires = [[Date.parse('2026-09-27T22:00:00Z'), 0], [Date.parse('2026-09-28T10:00:00Z'), 3580], + [Date.parse('2026-09-28T11:00:00Z'), 3740], [Date.parse('2026-09-29T10:00:00Z'), 5000]]; + const c = vrmVersCourbes(paires, new Date('2026-09-27T23:30:00Z')); + const vc = [ + ['VRM : jour de Paris (28/09) et non UTC, 24 valeurs', c, + (x) => x.day0 === '2026-09-28' && x.today.horaire.length === 24], + ['VRM : 10:00 UTC range a 12 h Paris, Wh -> kWh', c, + (x) => x.today.horaire[12] === 3.58 && x.today.horaire[13] === 3.74 && x.today.totalKwh === 7.3], + ['VRM : demain separe, format CSV du bilan', c, + (x) => x.tomorrow.horaire[12] === 5 && x.today.csv.split(',').length === 24 && /^\d{2}\/\d{2} \d{2}:\d{2}$/.test(x.updated)], + ['VRM : reponse vide -> ok false (le bilan garde sa source)', vrmVersCourbes([], new Date()), (x) => x.ok === false], + ]; + for (const [nom, x, test] of vc) { + const ok = test(x); if (!ok) { ko++; console.log(JSON.stringify(x).slice(0, 300)); } + console.log(`${ok ? 'OK ' : 'KO '} ${nom}`); + } + globalThis.__nConso = (globalThis.__nConso || 0) + vc.length; +} + const n = (globalThis.__nConso || 0) + cas.length + cel.length + prod.length + charge.length + 1 + bn.length + res.length + imp.length; console.log(`\n${n - ko}/${n}`); process.exit(ko ? 1 : 0); diff --git a/tools/diagnostics-agent.mjs b/tools/diagnostics-agent.mjs index 89ea7c7..c7a0cc9 100644 --- a/tools/diagnostics-agent.mjs +++ b/tools/diagnostics-agent.mjs @@ -541,7 +541,8 @@ export function projectionSoir(st) { statut: 'indetermine', soc_actuel_pct: soc, reste_solaire_prevu_kwh: reste, - reste_solaire_source: 'Forecast.Solar, non corrige', + reste_solaire_source: 'Forecast.Solar (sensor.energy_production_today_remaining), non corrige', + source_prevision_du_bilan: st.get('input_select.evcs_balance_forecast_source')?.state ?? null, ecart_prevision_7j_pct: ecart, explication: 'Je ne peux pas estimer la batterie de ce soir : la prevision solaire ' + `sous-estime la production${ecart !== null ? ` d'environ ${Math.round(ecart)} % ces sept derniers jours` : ''}, ` + @@ -595,7 +596,10 @@ export function previsionNuit(st, maintenant = new Date()) { contacteur_edf: reseau, session_reseau_nuit_engagee: session, voiture: branche === 'on' ? 'branchee' : branche === 'off' ? 'aucune' : 'inconnue', bilan_calcule_il_y_a_min: age, - reserve_prevision: 'la production du matin vient de Forecast.Solar, qui sous-estime ces jours-ci : l heure de retour du solaire peut etre plus precoce', + source_prevision: st.get('input_select.evcs_balance_forecast_source')?.state ?? null, + reserve_prevision: st.get('input_select.evcs_balance_forecast_source')?.state === 'vrm' + ? 'la production du matin vient de la prevision VRM, en essai depuis le 28/09 : l heure de retour du solaire reste une estimation' + : 'la production du matin vient de Forecast.Solar, qui sous-estime ces jours-ci : l heure de retour du solaire peut etre plus precoce', }; if (statut !== 'ok' || age === null || age > BILAN_FRAIS_MIN || aCouvrir === null || retour === null) { return { etat: 'indisponible', attributs: { ...attributs, statut: 'indisponible', @@ -614,3 +618,40 @@ export function previsionNuit(st, maintenant = new Date()) { `${session ? ' La session reseau de la nuit est engagee.' : ''}`; return { etat: reseauNecessaire ? reseauKwh : 0, attributs: { ...attributs, statut: 'frais', explication: texte, texte_rapport: texte } }; } + +// --------------------------------------------------------------------- +// Prevision VRM -> courbes horaires au format du bilan (28/09/2026) +// --------------------------------------------------------------------- +// VRM (API v2, stats?type=forecast&interval=hours) renvoie des paires +// [ms UTC debut d'heure, Wh]. Le bilan attend, par jour DE PARIS, 24 valeurs en +// kWh (index 0 = 00 h locale), une date AAAA-MM-JJ et un horodatage +// « JJ/MM HH:MM » : exactement ce que produit la chaine Forecast.Solar, pour +// que le bilan lise les deux sources indifferemment. +const jourParis = (ms) => new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }); +const heureNumParis = (ms) => Number(new Date(ms).toLocaleString('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', hour12: false })); + +export function vrmVersCourbes(paires, maintenant = new Date()) { + const jours = new Map(); + for (const [ms, wh] of paires || []) { + const v = Number(wh); + if (!Number.isFinite(Number(ms)) || !Number.isFinite(v)) continue; + const j = jourParis(Number(ms)); + if (!jours.has(j)) jours.set(j, new Array(24).fill(0)); + // Heure d'hiver/ete : l'heure en double du 25 octobre s'ajoute, jamais ne s'ecrase. + jours.get(j)[heureNumParis(Number(ms)) % 24] += Math.max(0, v) / 1000; + } + const auj = jourParis(maintenant.getTime()); + const dem = jourParis(maintenant.getTime() + 86400000); + const csv = (a) => a.map((x) => (Math.round(x * 100) / 100).toFixed(2)).join(','); + const total = (a) => Math.round(a.reduce((s, x) => s + x, 0) * 10) / 10; + const tA = jours.get(auj), tD = jours.get(dem); + const d = new Date(maintenant.toLocaleString('en-US', { timeZone: 'Europe/Paris' })); + const p2 = (n) => String(n).padStart(2, '0'); + return { + ok: !!tA, + day0: auj, + today: tA ? { csv: csv(tA), totalKwh: total(tA), horaire: tA.map((x) => Math.round(x * 100) / 100) } : null, + tomorrow: tD ? { csv: csv(tD), totalKwh: total(tD), horaire: tD.map((x) => Math.round(x * 100) / 100) } : null, + updated: `${p2(d.getDate())}/${p2(d.getMonth() + 1)} ${p2(d.getHours())}:${p2(d.getMinutes())}`, + }; +} diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index a69b6ad..23cba7b 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -30,7 +30,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import pg from 'pg'; import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs'; -import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit } from './diagnostics-agent.mjs'; +import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes } from './diagnostics-agent.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ONCE = process.argv.includes('--once'); @@ -408,7 +408,7 @@ async function ecrirePrevision(sql, st) { .toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }); // AAAA-MM-JJ const { rowCount } = await sql( `INSERT INTO prevision (publie_a, jour, total_kwh, horaire) - VALUES ($1,$2,$3,$4) ON CONFLICT (publie_a, jour) DO NOTHING`, + VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`, [publieA, jour, v.reduce((a, b) => a + b, 0), v] ); n += rowCount; @@ -667,6 +667,15 @@ async function migrer(client) { // de 1, et un recablage du LVHUB peut tout renumeroter — la valeur figee // mentirait sur l'historique. Absent des anciens releves => NULL. await client.query('ALTER TABLE cellule ADD COLUMN IF NOT EXISTS rang smallint'); + // 28/09 : deux sources de prevision enregistrees cote a cote (VRM et + // Forecast.Solar) pour les comparer a armes egales ; la cle doit donc porter + // la source, sinon deux publications de la meme seconde s'ecraseraient. + await client.query(`DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = 'prevision'::regclass AND i.indisprimary AND a.attname = 'source') THEN + ALTER TABLE prevision DROP CONSTRAINT IF EXISTS prevision_pkey; + ALTER TABLE prevision ADD PRIMARY KEY (publie_a, jour, source); + END IF; END $$`); // Le PACK d'origine (26/09/2026), lu dans le nom du fichier (-pack1-, -pack2-). // Depuis que deux lecteurs relevent deux chaines, le rang seul est ambigu : // chaque chaine numerote ses modules a partir de 1. Repere affiche = @@ -1433,6 +1442,9 @@ async function appliquerEtiquettesEnAttente(sql, client) { // ce soir, demain en entier — est la DERNIERE publiee, et c'est la serie // `attendue`. Ne pas les fondre en une seule : elles n'ont ni la meme // provenance ni le meme usage. +// Source de prevision active (selecteur HA, 28/09) : l'ecart prevision/reel et +// les courbes publiees portent sur ELLE, jamais sur un melange des deux. +let sourcePrevisionActive = 'forecast.solar'; async function publierPrevisionContreReel(client) { if (DRY) return 0; const { rows } = await client.query(` @@ -1441,14 +1453,14 @@ async function publierPrevisionContreReel(client) { -- revisee depuis. Mais c'est de l'HISTOIRE, pas une prevision. with matin as ( select distinct on (jour) jour, horaire from prevision - where jour between current_date - 6 and current_date + where jour between current_date - 6 and current_date and source = $1 and (publie_a at time zone 'Europe/Paris')::time < time '10:00' order by jour, publie_a desc), -- Ce qui est annonce MAINTENANT pour les heures a venir : la prevision au -- sens propre, aujourd'hui jusqu'a ce soir et demain en entier. courante as ( select distinct on (jour) jour, horaire from prevision - where jour >= current_date + where jour >= current_date and source = $1 order by jour, publie_a desc), maintenant as (select date_trunc('hour', now() at time zone 'Europe/Paris') h), passee as (select m.jour + (u.i - 1) * interval '1 hour' as heure, u.v kwh @@ -1487,7 +1499,7 @@ async function publierPrevisionContreReel(client) { left join reelle e on e.heure=h.heure left join censure c on c.heure=h.heure where h.heure >= current_date - 6 - order by h.heure`); + order by h.heure`, [sourcePrevisionActive]); // Ecart moyen sur les seules heures COMPARABLES : non bridees, et ou quelque // chose etait attendu ou produit. C'est le seul chiffre qui juge la prevision. @@ -1511,6 +1523,7 @@ async function publierPrevisionContreReel(client) { state: ecart === null ? 'unknown' : String(ecart), attributes: { friendly_name: 'Prévision solaire — écart sur les heures comparables', + source_prevision: sourcePrevisionActive, unit_of_measurement: '%', icon: 'mdi:chart-timeline-variant', heures_comparables: comparables.length, @@ -1943,6 +1956,44 @@ async function publierImportJour(st) { // Bilan de la nuit pour l'agent vocal : les donnees du rapport du matin (meme // code, donc memes chiffres que le courriel). Toutes les 5 min suffit. +// --------------------------------------------------------------------- +// PREVISION VRM (28/09/2026) — decision du proprietaire : le bilan passe sur +// VRM tout de suite, les deux sources sont enregistrees, comparaison dans une +// semaine. Le jeton reste dans le .env du depot (VRM_TOKEN), jamais dans HA : +// il agit avec les droits du compte VRM. Lecture seule. +// --------------------------------------------------------------------- +const VRM_SITE = process.env.VRM_SITE_ID || fromEnvFile('VRM_SITE_ID') || '298009'; +let vrmPublie = false; +async function publierPrevisionVrm(sql) { + const token = process.env.VRM_TOKEN || fromEnvFile('VRM_TOKEN'); + if (DRY || !token) return 0; + const maintenant = new Date(); + const minuit = minuitLocal(maintenant); + const debut = Math.floor(minuit.getTime() / 1000); + const fin = debut + 2 * 86400 + 3600; + const r = await fetch(`https://vrmapi.victronenergy.com/v2/installations/${VRM_SITE}/stats` + + `?type=forecast&interval=hours&start=${debut}&end=${fin}`, + { headers: { 'X-Authorization': `Token ${token}` }, signal: AbortSignal.timeout(20000) }); + if (!r.ok) throw new Error(`VRM HTTP ${r.status}`); + const j = await r.json(); + const c = vrmVersCourbes(j?.records?.solar_yield_forecast, maintenant); + if (!c.ok) throw new Error('VRM : pas de courbe pour aujourd hui'); + await ecrireHelperTexte('input_text.evcs_balance_vrm_day0', c.day0); + await ecrireHelperTexte('input_text.evcs_balance_vrm_today', c.today.csv); + if (c.tomorrow) await ecrireHelperTexte('input_text.evcs_balance_vrm_tomorrow', c.tomorrow.csv); + await ecrireHelperTexte('input_text.evcs_balance_vrm_updated', c.updated); + let n = 0; + for (const [jour, courbe] of [[c.day0, c.today], [minuitLocal(new Date(maintenant.getTime() + 86400000)).toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }), c.tomorrow]]) { + if (!courbe) continue; + const { rowCount } = await sql( + `INSERT INTO prevision (publie_a, jour, source, total_kwh, horaire) + VALUES ($1,$2,'vrm',$3,$4) ON CONFLICT DO NOTHING`, + [maintenant.toISOString(), jour, courbe.totalKwh, courbe.horaire]); + n += rowCount; + } + return n; +} + let bilanNuitPublie = false; // publie des le premier cycle, puis toutes les 5 min async function publierBilanNuit(client) { if (DRY || !client) return 0; @@ -2009,6 +2060,7 @@ async function cycle(client) { } : (q, p) => client.query(q, p); const st = await etats(); + sourcePrevisionActive = st.get('input_select.evcs_balance_forecast_source')?.state === 'vrm' ? 'vrm' : 'forecast.solar'; const r = { mesures: await ecrireMesures(sql, st), bilan: await ecrireBilan(sql, st), @@ -2040,6 +2092,9 @@ async function cycle(client) { : await publierPrevisionContreReel(client).catch((e) => { console.error(`prevision contre reel : ${e.message}`); return 0; }), + vrm: (DRY || (vrmPublie && new Date().getMinutes() % 15 !== 7)) ? 0 : await publierPrevisionVrm(sql).then((n) => { vrmPublie = true; return n; }).catch((e) => { + console.error(`prevision VRM : ${e.message}`); return 0; + }), bilanNuit: (DRY || (bilanNuitPublie && new Date().getMinutes() % 5 !== 1)) ? 0 : await publierBilanNuit(client).then((n) => { bilanNuitPublie = true; return n; }).catch((e) => { console.error(`bilan de nuit : ${e.message}`); return 0; }), diff --git a/workflows/evcs-energy-balance-v1.json b/workflows/evcs-energy-balance-v1.json index e816159..82f5772 100644 --- a/workflows/evcs-energy-balance-v1.json +++ b/workflows/evcs-energy-balance-v1.json @@ -51,7 +51,7 @@ "parameters": [ { "name": "template", - "value": "{{ {\n \"ts\": now().isoformat(),\n \"nowMs\": as_timestamp(now()) * 1000,\n\n \"balance_enabled\": states(\"input_boolean.evcs_balance_enabled\"),\n \"usable_capacity_kwh\": states(\"input_number.evcs_balance_usable_capacity_kwh\"),\n \"modules_en_service\": states(\"input_number.evcs_balance_modules_en_service\"),\n\n \"consumption_p50_csv\": states(\"input_text.evcs_balance_consumption_p50\"),\n \"consumption_p80_csv\": states(\"input_text.evcs_balance_consumption_p80\"),\n \"envelope_csv\": states(\"input_text.evcs_balance_envelope\"),\n \"censorship_rate_csv\": states(\"input_text.evcs_balance_censorship_rate\"),\n\n \"forecast_day0\": states(\"input_text.evcs_balance_forecast_day0\"),\n \"forecast_today_csv\": states(\"input_text.evcs_balance_forecast_today\"),\n \"forecast_tomorrow_csv\": states(\"input_text.evcs_balance_forecast_tomorrow\"),\n \"forecast_updated\": states(\"input_text.evcs_balance_forecast_updated\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n\n \"manual_hc_floor_percent\": states(\"input_number.evcs_battery_manual_hc_floor\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"battery_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"grid_switch\": states(\"switch.edf_general_ok_switch\"),\n \"acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n \"grid_power_l1_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"multiplus_state\": states(\"sensor.airtria_mpii_v1_state\"),\n \"multiplus_overload_alarm\": states(\"sensor.airtria_mpii_v1_overload_alarm\"),\n \"system_state\": states(\"sensor.gx_device_system_state\"),\n \"ess_batterylife_state\": states(\"sensor.gx_device_ess_batterylife_state\"),\n \"dc_battery_state\": states(\"sensor.gx_device_dc_battery_state\"),\n\n \"mppt_1_state\": states(\"sensor.airtria_mppt1_state\"),\n \"mppt_2_state\": states(\"sensor.mppt_450_200_hq2449yr9pt_state\"),\n \"mppt_3_state\": states(\"sensor.v1_state\"),\n\n \"evcs_native_status\": states(\"sensor.evcs_hq2239dnt7p_status\"),\n\n \"forecast_today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"forecast_today_kwh\": states(\"sensor.energy_production_today\"),\n \"forecast_tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n \"forecast_next_hour_w\": states(\"sensor.power_production_next_hour\"),\n\n \"sun_elevation\": state_attr(\"sun.sun\", \"elevation\"),\n\n \"storm_alert_level\": (states.sensor\n | selectattr('entity_id', 'search', '_weather_alert')\n | map(attribute='state') | list | first) | default('indisponible', true),\n \"storm_alert_detail\": (states.sensor\n | selectattr('entity_id', 'search', '_weather_alert')\n | map(attribute='attributes') | list | first) | default({}, true)\n} | tojson }}\n" + "value": "{{ {\n \"ts\": now().isoformat(),\n \"nowMs\": as_timestamp(now()) * 1000,\n\n \"balance_enabled\": states(\"input_boolean.evcs_balance_enabled\"),\n \"usable_capacity_kwh\": states(\"input_number.evcs_balance_usable_capacity_kwh\"),\n \"modules_en_service\": states(\"input_number.evcs_balance_modules_en_service\"),\n\n \"consumption_p50_csv\": states(\"input_text.evcs_balance_consumption_p50\"),\n \"consumption_p80_csv\": states(\"input_text.evcs_balance_consumption_p80\"),\n \"envelope_csv\": states(\"input_text.evcs_balance_envelope\"),\n \"censorship_rate_csv\": states(\"input_text.evcs_balance_censorship_rate\"),\n\n \"forecast_source\": states(\"input_select.evcs_balance_forecast_source\"),\n \"forecast_day0\": states(\"input_text.evcs_balance_vrm_day0\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_day0\"),\n \"forecast_today_csv\": states(\"input_text.evcs_balance_vrm_today\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_today\"),\n \"forecast_tomorrow_csv\": states(\"input_text.evcs_balance_vrm_tomorrow\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_tomorrow\"),\n \"forecast_updated\": states(\"input_text.evcs_balance_vrm_updated\") if is_state(\"input_select.evcs_balance_forecast_source\", \"vrm\") else states(\"input_text.evcs_balance_forecast_updated\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n\n \"manual_hc_floor_percent\": states(\"input_number.evcs_battery_manual_hc_floor\"),\n\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"vebus_ac_out_l1_power_w\": states(\"sensor.victron_ve_bus_ac_out_l1_power\"),\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"battery_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"vebus_dc_power_w\": states(\"sensor.victron_ve_bus_dc_power_system\"),\n\n \"grid_switch\": states(\"switch.edf_general_ok_switch\"),\n \"acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n \"grid_power_l1_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"multiplus_state\": states(\"sensor.airtria_mpii_v1_state\"),\n \"multiplus_overload_alarm\": states(\"sensor.airtria_mpii_v1_overload_alarm\"),\n \"system_state\": states(\"sensor.gx_device_system_state\"),\n \"ess_batterylife_state\": states(\"sensor.gx_device_ess_batterylife_state\"),\n \"dc_battery_state\": states(\"sensor.gx_device_dc_battery_state\"),\n\n \"mppt_1_state\": states(\"sensor.airtria_mppt1_state\"),\n \"mppt_2_state\": states(\"sensor.mppt_450_200_hq2449yr9pt_state\"),\n \"mppt_3_state\": states(\"sensor.v1_state\"),\n\n \"evcs_native_status\": states(\"sensor.evcs_hq2239dnt7p_status\"),\n\n \"forecast_today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"forecast_today_kwh\": states(\"sensor.energy_production_today\"),\n \"forecast_tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\"),\n \"forecast_next_hour_w\": states(\"sensor.power_production_next_hour\"),\n\n \"sun_elevation\": state_attr(\"sun.sun\", \"elevation\"),\n\n \"storm_alert_level\": (states.sensor\n | selectattr('entity_id', 'search', '_weather_alert')\n | map(attribute='state') | list | first) | default('indisponible', true),\n \"storm_alert_detail\": (states.sensor\n | selectattr('entity_id', 'search', '_weather_alert')\n | map(attribute='attributes') | list | first) | default({}, true)\n} | tojson }}\n" } ] },