From 49a6ee88dbb42b71da94c38ceacac2d91523aa84 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Thu, 17 Sep 2026 01:54:10 +0200 Subject: [PATCH] La prevision affichee cesse d etre de l histoire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le graphe « production prevue et realisee heure par heure » du troisieme onglet du bilan energetique ne montrait que du passe : sa courbe « prevue » etait la prevision publiee le matin de chaque jour ECOULE, et la fenetre s arretait a l heure en cours. C est un instrument de mesure du biais, pas une prevision. Deux courbes desormais, et elles n ont ni la meme provenance ni le meme usage. Pour un jour ecoule, ce qui avait ete annonce LE MATIN MEME reste la seule reference honnete : une prevision revisee a 18 h connait deja la journee, la comparer au reel ne mesure plus rien. Pour les heures a venir, c est la DERNIERE publication qui fait foi — aujourd hui jusqu au soir, demain en entier. Les deux se touchent sur l heure en cours au lieu de laisser un trou, et la seconde est tracee en pointilles. Au-dela de l heure en cours, « reel » et « bridage » valent NULL et non zero : un zero dessinerait une chute de production qui n a pas eu lieu. Defaut trouve en chemin : l ecart moyen comptait l heure EN COURS, mesuree sur ses seules minutes ecoulees. A 14h05 son reel vaut cinq minutes de production contre une prevision d heure pleine, soit -92 % qui n existent pas. La colonne `clos` l exclut. Et la serie mesuree s arrete desormais a la minute courante et non au debut de l heure, si bien que l heure en cours est reellement tracee au lieu de valoir zero. Le partage aujourd hui / demain se fait en SQL, dans le fuseau deja utilise par le reste de la requete : le conteneur repond en UTC a `date`, il ne doit pas servir d arbitre. Fenetre de la carte portee a hier / aujourd hui / demain (graph_span 3d, span end day offset +1d) : sans cela la prevision, qui est justement ce qu on veut voir, tombe hors du cadre. Verifie dans le paquet installe : `span.offset` exige bien un signe, et l offset decale les deux bornes. Verification en production : 47 points de prevision publies, du 17/09 01 h au 18/09 23 h, 33,3 kWh encore attendus aujourd hui et 34,5 annonces pour demain. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj --- dashboards/bilan_energetique.yaml | 77 +++++++++++++++++++++++-------- tools/historise-evcs.mjs | 72 +++++++++++++++++++++++------ 2 files changed, 116 insertions(+), 33 deletions(-) diff --git a/dashboards/bilan_energetique.yaml b/dashboards/bilan_energetique.yaml index 482bf3d..912283e 100644 --- a/dashboards/bilan_energetique.yaml +++ b/dashboards/bilan_energetique.yaml @@ -320,7 +320,15 @@ views: - type: markdown content: > - ## Comment lire cette courbe + ## Comment lire ces courbes + + + **La courbe pleine orange est de l'histoire, la pointillée est la + prévision.** Pour un jour écoulé on affiche ce qui avait été annoncé + *le matin même* — c'est la seule référence qui mesure vraiment la + qualité de la prévision, une version révisée à 18 h connaissant déjà + la journée. Pour les heures à venir on affiche la **dernière** + publication : aujourd'hui jusqu'à ce soir, et demain en entier. ⚠️ **Une heure bridée n'est pas comparable.** Quand la batterie est @@ -329,8 +337,9 @@ views: soit fausse. La bande grise du bas dit quand c'est le cas. - Le chiffre en tête — l'écart moyen — ne compte **que les heures non - bridées**. C'est le seul qui juge vraiment la prévision. + Le chiffre en tête — l'écart moyen sur sept jours — ne compte **que + les heures non bridées et déjà terminées**. C'est le seul qui juge + vraiment la prévision. Deux exemples mesurés : le 13/09 à 15 h, prévu 5,32 kWh et réalisé @@ -340,8 +349,21 @@ views: - type: entities entities: + - type: attribute + entity: sensor.evcs_prevision_contre_reel + attribute: reste_aujourdhui_kwh + name: Encore attendu aujourd'hui + suffix: " kWh" + icon: mdi:weather-sunny + - type: attribute + entity: sensor.evcs_prevision_contre_reel + attribute: annonce_demain_kwh + name: Annoncé pour demain + suffix: " kWh" + icon: mdi:weather-sunset-up + - type: divider - entity: sensor.evcs_prevision_contre_reel - name: Écart moyen (heures comparables) + name: Écart moyen (heures comparables, 7 j) - type: attribute entity: sensor.evcs_prevision_contre_reel attribute: heures_comparables @@ -349,49 +371,65 @@ views: - type: attribute entity: sensor.evcs_prevision_contre_reel attribute: heures_total - name: Heures dans la fenêtre + name: Heures terminées dans la fenêtre - type: custom:apexcharts-card header: show: true - title: Production prévue et réalisée, heure par heure + title: Production réalisée, et ce qui est annoncé jusqu'à demain soir show_states: false - graph_span: 7d + # Hier, aujourd'hui, demain. La fenêtre s'arrête a la fin de DEMAIN et + # non a l'heure en cours : sans cela la prevision, qui est justement ce + # qu'on veut voir, tombe hors du cadre. + graph_span: 3d span: - end: hour + end: day + offset: '+1d' apex_config: chart: height: 340 stroke: - width: [2, 2] + width: [1, 2, 2] + dashArray: [0, 0, 5] legend: position: top yaxis: - title: {text: kWh par heure} decimals: 1 + min: 0 series: - - entity: sensor.evcs_prevision_contre_reel - name: Prévue - type: line - color: '#f0a202' - data_generator: | - return (entity.attributes.predite || []); - entity: sensor.evcs_prevision_contre_reel name: Réalisée type: area opacity: 0.25 color: '#2d8cf0' data_generator: | - return (entity.attributes.reelle || []); + return (entity.attributes.reelle || []) + .filter(p => p[1] !== null && p[1] !== undefined); + - entity: sensor.evcs_prevision_contre_reel + name: Prévue le matin même + type: line + color: '#f0a202' + data_generator: | + return (entity.attributes.predite || []) + .filter(p => p[1] !== null && p[1] !== undefined); + - entity: sensor.evcs_prevision_contre_reel + name: Prévision en cours + type: line + color: '#f05a02' + data_generator: | + return (entity.attributes.attendue || []) + .filter(p => p[1] !== null && p[1] !== undefined); - type: custom:apexcharts-card header: show: true title: Part de l'heure où la production était bridée show_states: false - graph_span: 7d + graph_span: 3d span: - end: hour + end: day + offset: '+1d' apex_config: chart: height: 150 @@ -408,4 +446,5 @@ views: opacity: 0.4 color: '#9e9e9e' data_generator: | - return (entity.attributes.bridage || []); + return (entity.attributes.bridage || []) + .filter(p => p[1] !== null && p[1] !== undefined); diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 892857b..024cbf0 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -1375,18 +1375,39 @@ async function appliquerEtiquettesEnAttente(sql, client) { // 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. +// +// ⚠️ DEUX COURBES DE PREVISION, ET ELLES NE DISENT PAS LA MEME CHOSE. Pour un +// jour ECOULE, la seule reference honnete est ce qui avait ete annonce LE MATIN +// MEME : une prevision revisee a 18 h connait deja la journee, la comparer au +// reel ne mesure plus rien. Mais cette courbe-la est de l'HISTOIRE, et elle ne +// dit rien de ce qui vient. La prevision au sens propre — aujourd'hui jusqu'a +// 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. async function publierPrevisionContreReel(client) { if (DRY) return 0; const { rows } = await client.query(` - with prev as ( + -- Ce qui etait ANNONCE LE MATIN pour un jour deja ecoule. C'est la seule + -- reference qui mesure la qualite d'une prevision : celle qui n'a pas ete + -- revisee depuis. Mais c'est de l'HISTOIRE, pas une prevision. + with matin as ( select distinct on (jour) jour, horaire from prevision - where jour >= current_date - 6 + where jour between current_date - 6 and current_date 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)), + -- 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 + 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 + from matin m, unnest(m.horaire) with ordinality as u(v, i)), + futur as (select c.jour + (u.i - 1) * interval '1 hour' as heure, u.v kwh + from courante c, unnest(c.horaire) with ordinality as u(v, i)), mn as (select generate_series((current_date - 6)::timestamptz, - date_trunc('hour', now()), interval '1 minute') t), + date_trunc('minute', 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), @@ -1398,25 +1419,45 @@ async function publierPrevisionContreReel(client) { 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) + -- Les series passees s'arretent a l'heure en cours, les futures y + -- commencent : les deux courbes se TOUCHENT sur cette heure-la au lieu de + -- laisser un trou. Au-dela, "reel" et "bridage" valent NULL et non zero — + -- un zero dessinerait une chute de production qui n'a pas eu lieu. 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 + case when h.heure <= n.h then round(coalesce(p.kwh,0)::numeric,2)::float8 end as predite, + case when h.heure >= n.h then round(f.kwh::numeric,2)::float8 end as attendue, + case when h.heure <= n.h then round(coalesce(e.kwh,0)::numeric,2)::float8 end as reelle, + case when h.heure <= n.h then round(coalesce(c.taux,0)::numeric*100)::float8 end as bridage, + (h.heure < n.h) as clos, + (h.heure::date = (now() at time zone 'Europe/Paris')::date) as aujourdhui + from (select heure from passee union select heure from futur + union select heure from reelle) h + cross join maintenant n + left join passee p on p.heure=h.heure + left join futur f on f.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') + where h.heure >= current_date - 6 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)); + // + // ⚠️ `clos` EXCLUT L'HEURE EN COURS. Elle n'est mesuree que pour les minutes + // deja ecoulees : a 14h05 son "reel" vaut cinq minutes de production contre + // une prevision d'heure pleine, soit un ecart de -92 % qui n'existe pas. + const comparables = rows.filter((x) => x.clos && 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; + // Le partage aujourd'hui / demain se fait EN SQL, dans le fuseau deja utilise + // par tout le reste de la requete. Le refaire en JavaScript reviendrait a + // parier sur le fuseau du conteneur — qui est en UTC pour `date`, et ne doit + // donc pas servir d'arbitre. + const aVenir = rows.filter((x) => x.attendue !== null && x.attendue !== undefined); + const somme = (f) => Math.round(aVenir.filter(f).reduce((a, x) => a + x.attendue, 0) * 10) / 10; const corps = { state: ecart === null ? 'unknown' : String(ecart), attributes: { @@ -1424,8 +1465,11 @@ async function publierPrevisionContreReel(client) { unit_of_measurement: '%', icon: 'mdi:chart-timeline-variant', heures_comparables: comparables.length, - heures_total: rows.length, + heures_total: rows.filter((x) => x.clos).length, + reste_aujourdhui_kwh: somme((x) => x.aujourdhui), + annonce_demain_kwh: somme((x) => !x.aujourdhui), predite: rows.map((x) => [Number(x.ms), x.predite]), + attendue: rows.map((x) => [Number(x.ms), x.attendue]), reelle: rows.map((x) => [Number(x.ms), x.reelle]), bridage: rows.map((x) => [Number(x.ms), x.bridage]), },