Prevision contre reel : la serie que Home Assistant ne peut pas produire

Le proprietaire veut comparer prevision et production heure par heure sur une
semaine, dans son tableau de bilan. Les courbes de prevision vivent dans des
input_text ECRASES a chaque publication : HA n en garde aucun historique. La
base, elle, a treize jours de previsions horaires et la production mesuree.

Le service pousse donc la serie dans un capteur cree par l API HA, dont les
attributs portent les tableaux — un input_text plafonne a 255 caracteres ne
tiendrait pas 168 points.

⚠️ Premiere ecriture de ce service vers Home Assistant, qui a toujours ete
lecteur. Perimetre minuscule et delibere : un capteur, son propre espace de
noms, aucun actionneur.

⚠️ Et la troisieme serie n est pas decorative. Une heure BRIDEE n est pas
comparable : production sous la prevision peut vouloir dire mauvaise prevision
OU batterie pleine. Les mesures le montrent — le 13/09 a 15 h, prevu 5,32 et
realise 1,82 avec 100 % de bridage, l ecart n est pas meteorologique ; le 15/09
a 13 h, prevu 3,92 et realise 7,12 sans aucun bridage, la prevision SOUS-estime
de 82 %. Sans la bande, la courbe ferait accuser la meteo a tort.

L etat du capteur porte l ecart moyen sur les seules heures comparables — non
bridees — qui est le seul chiffre qui juge vraiment la prevision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
This commit is contained in:
Tallagrand Thierry
2026-09-15 18:12:53 +02:00
parent cfb63f072b
commit b6010b9342

View File

@@ -1279,6 +1279,92 @@ async function appliquerEtiquettesEnAttente(sql, client) {
return n; return n;
} }
// ---------------------------------------------------------------------
// PREVISION CONTRE REEL — la serie que Home Assistant ne peut pas produire
// ---------------------------------------------------------------------
// Les courbes de prevision vivent dans des `input_text` ECRASES a chaque
// publication : Home Assistant n'en garde aucun historique exploitable. La base,
// elle, a 13 jours de previsions horaires (table `prevision`) ET la production
// mesuree. C'est donc ici qu'on peut comparer.
//
// Le service pousse la serie dans un capteur HA cree par l'API, dont les
// ATTRIBUTS portent les tableaux — un `input_text` plafonne a 255 caracteres ne
// tiendrait pas 168 points. La carte ApexCharts les lit directement.
//
// ⚠️ C'est la PREMIERE ecriture de ce service vers Home Assistant : il a
// toujours ete lecteur. Le perimetre est volontairement minuscule — UN capteur,
// dans son propre espace de noms, aucun actionneur, et un capteur cree par API
// disparait au redemarrage de HA puis se recree au cycle suivant.
//
// ⚠️ ET LA REGLE DE LECTURE EST DANS LA DONNEE : une heure BRIDEE n'est pas
// comparable. Production sous la prevision peut vouloir dire mauvaise prevision
// OU batterie pleine, et seules les heures non bridees permettent de juger.
// D'ou la troisieme serie, sans laquelle la carte ferait accuser la meteo a tort.
async function publierPrevisionContreReel(client) {
if (DRY) return 0;
const { rows } = await client.query(`
with prev as (
select distinct on (jour) jour, horaire from prevision
where jour >= current_date - 6
and (publie_a at time zone 'Europe/Paris')::time < time '10:00'
order by jour, publie_a desc),
predite as (select p.jour + (h.i - 1) * interval '1 hour' as heure, h.v kwh
from prev p, unnest(p.horaire) with ordinality as h(v, i)),
mn as (select generate_series((current_date - 6)::timestamptz,
date_trunc('hour', now()), interval '1 minute') t),
v as (select date_trunc('minute', ts) t, (array_agg(valeur order by ts desc))[1] v
from mesure where cle='sensor.victron_pv_power_system'
and ts >= current_date - 6 group by 1),
j as (select mn.t, v.v, count(v.v) over (order by mn.t) g
from mn left join v on v.t=mn.t),
r as (select t, first_value(v) over (partition by g order by t) w from j),
reelle as (select date_trunc('hour', t at time zone 'Europe/Paris') heure,
sum(w)/60/1000 kwh from r where w is not null group by 1),
censure as (select date_trunc('hour', ts at time zone 'Europe/Paris') heure,
avg(case when pv_censure then 1.0 else 0.0 end) taux
from bilan where ts >= current_date - 6 group by 1)
select extract(epoch from h.heure at time zone 'Europe/Paris')*1000 as ms,
round(coalesce(p.kwh,0)::numeric,2)::float8 as predite,
round(coalesce(e.kwh,0)::numeric,2)::float8 as reelle,
round(coalesce(c.taux,0)::numeric*100)::float8 as bridage
from (select heure from predite union select heure from reelle) h
left join predite p on p.heure=h.heure
left join reelle e on e.heure=h.heure
left join censure c on c.heure=h.heure
-- Pas d'heures futures : leur "reel" vaut 0 et creuserait un faux ecart.
where h.heure <= date_trunc('hour', now() at time zone 'Europe/Paris')
order by h.heure`);
// 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.
const comparables = rows.filter((x) => x.bridage < 20 && (x.predite > 0.2 || x.reelle > 0.2));
const sp = comparables.reduce((a, x) => a + x.predite, 0);
const sr = comparables.reduce((a, x) => a + x.reelle, 0);
const ecart = sp > 0 ? Math.round((sr / sp - 1) * 100) : null;
const corps = {
state: ecart === null ? 'unknown' : String(ecart),
attributes: {
friendly_name: 'Prévision solaire — écart sur les heures comparables',
unit_of_measurement: '%',
icon: 'mdi:chart-timeline-variant',
heures_comparables: comparables.length,
heures_total: rows.length,
predite: rows.map((x) => [Number(x.ms), x.predite]),
reelle: rows.map((x) => [Number(x.ms), x.reelle]),
bridage: rows.map((x) => [Number(x.ms), x.bridage]),
},
};
const r = await fetch(`${HA_URL}/api/states/sensor.evcs_prevision_contre_reel`, {
method: 'POST',
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify(corps),
signal: AbortSignal.timeout(10000),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return rows.length;
}
async function cycle(client) { async function cycle(client) {
// En blanc, on compte ce qu'on ECRIRAIT. Un essai a blanc qui renvoie // En blanc, on compte ce qu'on ECRIRAIT. Un essai a blanc qui renvoie
// toujours zero ne verifie rien — c'est le piege classique du test qui ne // toujours zero ne verifie rien — c'est le piege classique du test qui ne
@@ -1308,6 +1394,11 @@ async function cycle(client) {
// Ingestion des relevés de cellule deposes par le poste au cable serie. // Ingestion des relevés de cellule deposes par le poste au cable serie.
// Hors essai a blanc : l'insertion est massive et idempotente, la simuler // Hors essai a blanc : l'insertion est massive et idempotente, la simuler
// n'apprendrait rien. // n'apprendrait rien.
// Serie horaire : inutile de la republier toutes les minutes.
prevreel: (DRY || new Date().getMinutes() % 10 !== 3) ? 0
: await publierPrevisionContreReel(client).catch((e) => {
console.error(`prevision contre reel : ${e.message}`); return 0;
}),
rapport: DRY ? 0 : await envoyerRapportDuMatin(sql, client).catch((e) => { rapport: DRY ? 0 : await envoyerRapportDuMatin(sql, client).catch((e) => {
console.error('rapport de nuit :', e.message); return 0; console.error('rapport de nuit :', e.message); return 0;
}), }),