diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 63bed2c..8456b43 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -1279,6 +1279,92 @@ async function appliquerEtiquettesEnAttente(sql, client) { 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) { // 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 @@ -1308,6 +1394,11 @@ async function cycle(client) { // 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 // 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) => { console.error('rapport de nuit :', e.message); return 0; }),