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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z
This commit is contained in:
@@ -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())}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user