diff --git a/HANDOFF.md b/HANDOFF.md index 0ba963c..ee8b02f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5614,9 +5614,28 @@ tels quels a `curl`, d'ou le 401. Retirer les guillemets encadrants suffit : ```sh V=$(sudo sed -n 's/^GRAFANA_ADMIN_PASSWORD=//p' .env | tr -d '\r\n') -PW=$(printf '%s' "$V" | sed -e 's/^"//' -e 's/"$//') +PW=$(printf '%s' "$V" | sed -e "s/^['\"]//" -e "s/['\"]$//") ``` +⚠️ **CORRECTION du 17/09/2026 : ce sont des apostrophes SIMPLES, pas des +guillemets doubles** — verifie a l'octet (`od -c` sur le premier et le dernier +caractere). La commande ci-dessus a ete elargie aux deux formes ; celle que +j'avais ecrite le 08/09 ne retirait rien et rendait donc toujours 401. + +⚠️ **ET GRAFANA BLOQUE LE COMPTE APRES QUELQUES ECHECS** : +`too many consecutive incorrect login attempts for user - login for user +temporarily blocked`. En sondant le mot de passe le 17/09 j'ai verrouille +`admin` pour quelques minutes — **le proprietaire ne pouvait plus se +connecter a son interface non plus**. Ne jamais essayer un mot de passe en +boucle sur cette API : extraire, verifier la longueur, essayer UNE fois. +Consequence pratique : une fois le compte bloque, un mot de passe CORRECT est +lui aussi refuse, donc l'echec ne prouve plus rien. + +**Et l'API n'est pas necessaire pour verifier un tableau provisionne** : le +fournisseur lit les FICHIERS montes (`updateIntervalSeconds: 30`), pas l'API. +Il suffit donc de controler le contenu du fichier vu de l'interieur du +conteneur et l'absence de ligne `provision` en erreur dans son journal. + L'API repond alors du premier coup. Interroger un panneau se fait par `/api/ds/query` depuis l'interieur du conteneur `evcs-grafana` : @@ -6951,3 +6970,64 @@ la confiance dans la detection.** --- *Document mis a jour le : 17/09/2026 (~02:20) — chien de garde : `startedAt` absent lu comme une panne, valeur de repos divergente, confirmation sur deux cycles* + +## 71. Session du 17/09/2026 — La prevision entre dans les tableaux Grafana + +Suite du correctif de l'onglet « Prevision vs reel » de Home Assistant : le +proprietaire a choisi **« La journee » d'abord, puis la ligne sur le +telephone**. + +### « La journee » (`evcs-journee`) +Ce tableau avait exactement le meme angle mort : cadre sur `now/d`, il +s'arretait a l'heure en cours. Une journee dont on ne montre que la moitie +ecoulee ne repond pas a la question qu'on se pose en l'ouvrant a 10 h. + +- Fenetre portee a **`now/d` → `now/d`** (la journee ENTIERE, fin du jour + comprise). `dashboard()` gagne un parametre `jusqu`. +- Le graphe horaire gagne une serie **« Encore attendu »**, gris translucide, + a la suite des barres mesurees. +- Le bandeau du haut gagne une colonne **« Reste attendu »**, a cote de + « Solaire produit ». Deux chiffres cote a cote : ce qui est fait, ce qui vient. + +### « Sur le telephone » (`evcs-telephone`) +Aucun panneau ajoute — la contrainte de ce tableau est qu'un panneau de plus +est un ecran de plus a faire defiler. Les deux chiffres rejoignent donc des +bandeaux existants, chacun la ou il sert : +- **« Soleil restant »** dans *Maintenant*, a cote de « Excedent » : ce qui + reste a capter aujourd'hui ; +- **« Soleil demain »** dans *La nuit qui vient* : c'est lui qui decide jusqu'ou + la batterie peut descendre cette nuit. + +Releve au deploiement : 60 % de batterie, marge +18,6, **37,0 kWh restants +aujourd'hui**, 4,6 kWh a couvrir, relais du solaire a 09 h, **28,4 kWh annonces +demain**. + +### ⚠️ TROIS PIEGES RENCONTRES, tous dans la meme direction +1. **La mesure ne doit pas suivre la fenetre dans le futur.** `ENERGIE_SQL` + construisait son axe entre `$__timeFrom()` et `$__timeTo()` puis reportait la + derniere valeur connue. Etendre la fenetre a la fin du jour aurait rempli les + heures a venir avec la puissance de l'instant et **gonfle l'energie du jour**. + Sa borne haute est desormais `least($__timeTo(), now())`. + `BILAN_BASE` etait deja immunise : il derive ses bornes de `min(ts)/max(ts)` + des donnees, pas des macros. +2. **`FULL OUTER JOIN`, pas `LEFT`.** Une periode a venir n'a AUCUNE ligne de + mesure et une periode passee aucune prevision ; un `LEFT JOIN` depuis la + mesure aurait fait disparaitre toutes les barres de prevision. +3. **Borne haute STRICTE.** Avec `<=`, « La journee » affichait une barre + `18/09 00 h`. Une periode qui commence pile a la fin de la fenetre appartient + a la suivante. + +Et une regle de contenu : **l'heure EN COURS n'a pas de barre de prevision**. +Elle est deja mesuree pour ses minutes ecoulees ; y superposer une prevision +d'heure pleine ferait compter deux fois la meme heure. + +### Verification +`verifier-dashboards.mjs` : 73/73. Mais — troisieme rappel — il prouve qu'une +requete s'EXECUTE, pas qu'elle rend la bonne grandeur. Les deux requetes de +« La journee » ont donc ete rejouees a la main avec la vraie fenetre +(`date_trunc('day', now())` → +1 jour) : 24 lignes, mesures jusqu'a 02 h, +prevision de 03 h a 23 h, somme des barres 36,95 kWh contre 37,0 annonces par +le bandeau. C'est ce recoupement qui a fait apparaitre le piege n°3. + +--- +*Document mis a jour le : 17/09/2026 (~02:40) — prevision dans « La journee » et sur le telephone, 73/73* diff --git a/deploy/historisation/grafana/dashboards/journee.json b/deploy/historisation/grafana/dashboards/journee.json index 1f946ac..3a70c3e 100644 --- a/deploy/historisation/grafana/dashboards/journee.json +++ b/deploy/historisation/grafana/dashboards/journee.json @@ -15,7 +15,7 @@ "refresh": "1m", "time": { "from": "now/d", - "to": "now" + "to": "now/d" }, "timepicker": { "refresh_intervals": [ @@ -62,7 +62,7 @@ { "type": "table", "title": "Le bilan depuis minuit", - "description": "Une ligne d'en-tetes, une ligne de valeurs. L'autonomie est la part de la consommation de la maison qui n'est PAS venue du reseau. « Batterie rendue » est ce qu'elle a restitue, donc du solaire differe et non une consommation supplementaire.", + "description": "Une ligne d'en-tetes, une ligne de valeurs. L'autonomie est la part de la consommation de la maison qui n'est PAS venue du reseau. « Batterie rendue » est ce qu'elle a restitue, donc du solaire differe et non une consommation supplementaire. « Reste attendu » est la seule colonne qui ne soit pas une mesure : c'est ce que la derniere prevision annonce pour les heures qui restent.", "datasource": { "type": "grafana-postgresql-datasource", "uid": "evcs-pg" @@ -77,7 +77,7 @@ "format": "table", "rawQuery": true, "editorMode": "code", - "rawSql": "WITH bornes AS (\n SELECT min(ts) AS d, max(ts) AS f FROM mesure WHERE $__timeFilter(ts)\n),\naxe AS (\n SELECT generate_series(date_trunc('minute', (SELECT d FROM bornes)),\n date_trunc('minute', (SELECT f FROM bornes)),\n interval '1 minute') AS t\n),\nreleve AS (\n SELECT date_trunc('minute', ts) AS t,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_pv_power_system') AS pv,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_pv_power_system'))[1] AS d_pv,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS maison,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1'))[1] AS d_maison,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_grid_power_l1') AS reseau,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_grid_power_l1'))[1] AS d_reseau,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power') AS voiture,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power'))[1] AS d_voiture,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_battery_power') AS batterie,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_battery_power'))[1] AS d_batterie\n FROM mesure WHERE $__timeFilter(ts) GROUP BY 1\n),\npar_minute AS (\n SELECT axe.t, releve.pv, releve.d_pv, releve.maison, releve.d_maison, releve.reseau, releve.d_reseau, releve.voiture, releve.d_voiture, releve.batterie, releve.d_batterie\n FROM axe LEFT JOIN releve USING (t)\n),\npaliers AS (\n SELECT t, pv, d_pv, maison, d_maison, reseau, d_reseau, voiture, d_voiture, batterie, d_batterie,\n count(d_pv) OVER (ORDER BY t) AS g_pv,\n count(d_maison) OVER (ORDER BY t) AS g_maison,\n count(d_reseau) OVER (ORDER BY t) AS g_reseau,\n count(d_voiture) OVER (ORDER BY t) AS g_voiture,\n count(d_batterie) OVER (ORDER BY t) AS g_batterie\n FROM par_minute\n),\nreport AS (\n SELECT t,\n coalesce(pv, first_value(d_pv) OVER (PARTITION BY g_pv ORDER BY t), 0) AS pv,\n coalesce(maison, first_value(d_maison) OVER (PARTITION BY g_maison ORDER BY t), 0) AS maison,\n coalesce(reseau, first_value(d_reseau) OVER (PARTITION BY g_reseau ORDER BY t), 0) AS reseau,\n coalesce(voiture, first_value(d_voiture) OVER (PARTITION BY g_voiture ORDER BY t), 0) AS voiture,\n coalesce(batterie, first_value(d_batterie) OVER (PARTITION BY g_batterie ORDER BY t), 0) AS batterie\n FROM paliers\n)\nSELECT\n round((sum(pv) / 60.0 / 1000.0)::numeric, 1) AS \"Solaire produit\",\n round((sum(maison) / 60.0 / 1000.0)::numeric, 1) AS \"Maison consommee\",\n round((sum(reseau) / 60.0 / 1000.0)::numeric, 1) AS \"Reseau importe\",\n round((sum(voiture) / 60.0 / 1000.0)::numeric, 1) AS \"Voiture\",\n round((sum(greatest(batterie, 0)) / 60.0 / 1000.0)::numeric, 1) AS \"Batterie chargee\",\n round((sum(greatest(-batterie, 0)) / 60.0 / 1000.0)::numeric, 1) AS \"Batterie rendue\",\n round((100.0 * greatest(sum(maison) - sum(reseau), 0)\n / nullif(sum(maison), 0))::numeric, 0) AS \"Autonomie\"\nFROM report GROUP BY (1 = 1)" + "rawSql": "WITH bornes AS (\n SELECT min(ts) AS d, max(ts) AS f FROM mesure WHERE $__timeFilter(ts)\n),\naxe AS (\n SELECT generate_series(date_trunc('minute', (SELECT d FROM bornes)),\n date_trunc('minute', (SELECT f FROM bornes)),\n interval '1 minute') AS t\n),\nreleve AS (\n SELECT date_trunc('minute', ts) AS t,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_pv_power_system') AS pv,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_pv_power_system'))[1] AS d_pv,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS maison,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1'))[1] AS d_maison,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_grid_power_l1') AS reseau,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_grid_power_l1'))[1] AS d_reseau,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power') AS voiture,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power'))[1] AS d_voiture,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_battery_power') AS batterie,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_battery_power'))[1] AS d_batterie\n FROM mesure WHERE $__timeFilter(ts) GROUP BY 1\n),\npar_minute AS (\n SELECT axe.t, releve.pv, releve.d_pv, releve.maison, releve.d_maison, releve.reseau, releve.d_reseau, releve.voiture, releve.d_voiture, releve.batterie, releve.d_batterie\n FROM axe LEFT JOIN releve USING (t)\n),\npaliers AS (\n SELECT t, pv, d_pv, maison, d_maison, reseau, d_reseau, voiture, d_voiture, batterie, d_batterie,\n count(d_pv) OVER (ORDER BY t) AS g_pv,\n count(d_maison) OVER (ORDER BY t) AS g_maison,\n count(d_reseau) OVER (ORDER BY t) AS g_reseau,\n count(d_voiture) OVER (ORDER BY t) AS g_voiture,\n count(d_batterie) OVER (ORDER BY t) AS g_batterie\n FROM par_minute\n),\nreport AS (\n SELECT t,\n coalesce(pv, first_value(d_pv) OVER (PARTITION BY g_pv ORDER BY t), 0) AS pv,\n coalesce(maison, first_value(d_maison) OVER (PARTITION BY g_maison ORDER BY t), 0) AS maison,\n coalesce(reseau, first_value(d_reseau) OVER (PARTITION BY g_reseau ORDER BY t), 0) AS reseau,\n coalesce(voiture, first_value(d_voiture) OVER (PARTITION BY g_voiture ORDER BY t), 0) AS voiture,\n coalesce(batterie, first_value(d_batterie) OVER (PARTITION BY g_batterie ORDER BY t), 0) AS batterie\n FROM paliers\n)\nSELECT\n round((sum(pv) / 60.0 / 1000.0)::numeric, 1) AS \"Solaire produit\",\n round((sum(maison) / 60.0 / 1000.0)::numeric, 1) AS \"Maison consommee\",\n round((sum(reseau) / 60.0 / 1000.0)::numeric, 1) AS \"Reseau importe\",\n round((sum(voiture) / 60.0 / 1000.0)::numeric, 1) AS \"Voiture\",\n round((sum(greatest(batterie, 0)) / 60.0 / 1000.0)::numeric, 1) AS \"Batterie chargee\",\n round((sum(greatest(-batterie, 0)) / 60.0 / 1000.0)::numeric, 1) AS \"Batterie rendue\",\n round((100.0 * greatest(sum(maison) - sum(reseau), 0)\n / nullif(sum(maison), 0))::numeric, 0) AS \"Autonomie\",\n -- Ce qui reste a venir aujourd'hui, a cote de ce qui est deja fait. Sous-\n -- requete independante de `report` : elle ne touche a aucun total mesure.\n (SELECT round(coalesce(sum(kwh), 0)::numeric, 1)\n FROM (\n SELECT (c.jour + (u.i - 1) * interval '1 hour') AT TIME ZONE 'Europe/Paris' AS heure,\n u.v AS kwh\n FROM (SELECT DISTINCT ON (jour) jour, horaire FROM prevision\n WHERE jour >= current_date ORDER BY jour, publie_a DESC) c,\n unnest(c.horaire) WITH ORDINALITY AS u(v, i)\n WHERE (c.jour + (u.i - 1) * interval '1 hour') AT TIME ZONE 'Europe/Paris'\n > date_trunc('hour', now())\n) p\n WHERE p.heure < date_trunc('day', now()) + interval '1 day') AS \"Reste attendu\"\nFROM report GROUP BY (1 = 1)" } ], "gridPos": { @@ -219,6 +219,35 @@ } ] }, + { + "matcher": { + "id": "byName", + "options": "Reste attendu" + }, + "properties": [ + { + "id": "unit", + "value": "kwatth" + }, + { + "id": "decimals", + "value": 1 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#9e9e9e" + } + } + ] + }, { "matcher": { "id": "byName", @@ -274,7 +303,7 @@ { "type": "barchart", "title": "Energie heure par heure", - "description": "La forme de la journee : quand le solaire a couvert la maison, et quand il a fallu acheter. Le pas suit la periode choisie.", + "description": "La forme de la journee : quand le solaire a couvert la maison, quand il a fallu acheter — et, a la suite des barres mesurees, ce que la derniere prevision annonce pour les heures qui restent. L'heure EN COURS n'a pas de barre de prevision : elle est deja mesuree pour ses minutes ecoulees.", "datasource": { "type": "grafana-postgresql-datasource", "uid": "evcs-pg" @@ -289,7 +318,7 @@ "format": "table", "rawQuery": true, "editorMode": "code", - "rawSql": "WITH bornes AS (\n SELECT $__timeFrom()::timestamptz AS d, $__timeTo()::timestamptz AS f\n),\n-- Le pas s'adapte a la plage choisie. Sans cela, « 30 derniers jours » rend\n-- 720 barres illisibles — c'est exactement ce que fait VRM en passant de\n-- l'heure au jour puis au mois.\npas AS (\n SELECT CASE WHEN f - d > interval '70 days' THEN 'month'\n WHEN f - d > interval '4 days' THEN 'day'\n ELSE 'hour' END AS unite,\n CASE WHEN f - d > interval '70 days' THEN 'MM/YYYY'\n WHEN f - d > interval '4 days' THEN 'DD/MM'\n ELSE 'DD/MM HH24' END AS forme\n FROM bornes\n),\naxe AS (\n SELECT generate_series(date_trunc('minute', (SELECT d FROM bornes)),\n date_trunc('minute', (SELECT f FROM bornes)),\n interval '1 minute') AS t\n),\nreleve AS (\n SELECT date_trunc('minute', ts) AS t,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_pv_power_system') AS Solaire,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_pv_power_system'))[1] AS d_Solaire,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_grid_power_l1') AS Reseau,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_grid_power_l1'))[1] AS d_Reseau,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS Maison,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1'))[1] AS d_Maison,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power') AS Voiture,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power'))[1] AS d_Voiture\n FROM mesure WHERE $__timeFilter(ts) GROUP BY 1\n),\npar_minute AS (\n SELECT axe.t, releve.Solaire, releve.d_Solaire, releve.Reseau, releve.d_Reseau, releve.Maison, releve.d_Maison, releve.Voiture, releve.d_Voiture\n FROM axe LEFT JOIN releve USING (t)\n),\npaliers AS (\n SELECT t, Solaire, d_Solaire, Reseau, d_Reseau, Maison, d_Maison, Voiture, d_Voiture,\n count(d_Solaire) OVER (ORDER BY t) AS g_Solaire,\n count(d_Reseau) OVER (ORDER BY t) AS g_Reseau,\n count(d_Maison) OVER (ORDER BY t) AS g_Maison,\n count(d_Voiture) OVER (ORDER BY t) AS g_Voiture\n FROM par_minute\n),\nreport AS (\n SELECT t,\n coalesce(Solaire, first_value(d_Solaire) OVER (PARTITION BY g_Solaire ORDER BY t)) AS Solaire,\n coalesce(Reseau, first_value(d_Reseau) OVER (PARTITION BY g_Reseau ORDER BY t)) AS Reseau,\n coalesce(Maison, first_value(d_Maison) OVER (PARTITION BY g_Maison ORDER BY t)) AS Maison,\n coalesce(Voiture, first_value(d_Voiture) OVER (PARTITION BY g_Voiture ORDER BY t)) AS Voiture\n FROM paliers\n)\nSELECT to_char(date_trunc(pas.unite, t), pas.forme) AS \"Heure\",\n round((avg(Solaire) * count(Solaire) / 60.0 / 1000.0)::numeric, 2) AS \"Solaire\",\n round((avg(Reseau) * count(Reseau) / 60.0 / 1000.0)::numeric, 2) AS \"Reseau\",\n round((avg(Maison) * count(Maison) / 60.0 / 1000.0)::numeric, 2) AS \"Maison\",\n round((avg(Voiture) * count(Voiture) / 60.0 / 1000.0)::numeric, 2) AS \"Voiture\"\nFROM report, pas\nGROUP BY date_trunc(pas.unite, t), pas.forme\nHAVING count(Solaire) > 0 OR count(Reseau) > 0 OR count(Maison) > 0 OR count(Voiture) > 0\nORDER BY date_trunc(pas.unite, t)" + "rawSql": "WITH bornes AS (\n -- ⚠️ LA MESURE NE PEUT PAS DEPASSER MAINTENANT. « La journee » s'etend\n -- desormais jusqu'a la fin du jour pour montrer la prevision ; sans ce\n -- garde-fou, le report de la derniere valeur connue (plus bas) remplirait les\n -- heures a venir avec la puissance de l'instant et gonflerait l'energie.\n SELECT $__timeFrom()::timestamptz AS d,\n least($__timeTo()::timestamptz, now()) AS f\n),\n-- Le pas s'adapte a la plage choisie. Sans cela, « 30 derniers jours » rend\n-- 720 barres illisibles — c'est exactement ce que fait VRM en passant de\n-- l'heure au jour puis au mois.\npas AS (\n SELECT CASE WHEN f - d > interval '70 days' THEN 'month'\n WHEN f - d > interval '4 days' THEN 'day'\n ELSE 'hour' END AS unite,\n CASE WHEN f - d > interval '70 days' THEN 'MM/YYYY'\n WHEN f - d > interval '4 days' THEN 'DD/MM'\n ELSE 'DD/MM HH24' END AS forme\n FROM bornes\n),\naxe AS (\n SELECT generate_series(date_trunc('minute', (SELECT d FROM bornes)),\n date_trunc('minute', (SELECT f FROM bornes)),\n interval '1 minute') AS t\n),\nreleve AS (\n SELECT date_trunc('minute', ts) AS t,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_pv_power_system') AS Solaire,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_pv_power_system'))[1] AS d_Solaire,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_grid_power_l1') AS Reseau,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_grid_power_l1'))[1] AS d_Reseau,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS Maison,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1'))[1] AS d_Maison,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power') AS Voiture,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power'))[1] AS d_Voiture\n FROM mesure WHERE $__timeFilter(ts) GROUP BY 1\n),\npar_minute AS (\n SELECT axe.t, releve.Solaire, releve.d_Solaire, releve.Reseau, releve.d_Reseau, releve.Maison, releve.d_Maison, releve.Voiture, releve.d_Voiture\n FROM axe LEFT JOIN releve USING (t)\n),\npaliers AS (\n SELECT t, Solaire, d_Solaire, Reseau, d_Reseau, Maison, d_Maison, Voiture, d_Voiture,\n count(d_Solaire) OVER (ORDER BY t) AS g_Solaire,\n count(d_Reseau) OVER (ORDER BY t) AS g_Reseau,\n count(d_Maison) OVER (ORDER BY t) AS g_Maison,\n count(d_Voiture) OVER (ORDER BY t) AS g_Voiture\n FROM par_minute\n),\nreport AS (\n SELECT t,\n coalesce(Solaire, first_value(d_Solaire) OVER (PARTITION BY g_Solaire ORDER BY t)) AS Solaire,\n coalesce(Reseau, first_value(d_Reseau) OVER (PARTITION BY g_Reseau ORDER BY t)) AS Reseau,\n coalesce(Maison, first_value(d_Maison) OVER (PARTITION BY g_Maison ORDER BY t)) AS Maison,\n coalesce(Voiture, first_value(d_Voiture) OVER (PARTITION BY g_Voiture ORDER BY t)) AS Voiture\n FROM paliers\n),\nmesure_par_periode AS (\n SELECT date_trunc(pas.unite, t) AS b,\n round((avg(Solaire) * count(Solaire) / 60.0 / 1000.0)::numeric, 2) AS \"Solaire\",\n round((avg(Reseau) * count(Reseau) / 60.0 / 1000.0)::numeric, 2) AS \"Reseau\",\n round((avg(Maison) * count(Maison) / 60.0 / 1000.0)::numeric, 2) AS \"Maison\",\n round((avg(Voiture) * count(Voiture) / 60.0 / 1000.0)::numeric, 2) AS \"Voiture\"\n FROM report, pas\n GROUP BY date_trunc(pas.unite, t)\n HAVING count(Solaire) > 0 OR count(Reseau) > 0 OR count(Maison) > 0 OR count(Voiture) > 0\n)\n\n, a_venir AS (\n SELECT date_trunc((SELECT unite FROM pas), p.heure) AS b,\n sum(p.kwh) AS kwh\n FROM (\n SELECT (c.jour + (u.i - 1) * interval '1 hour') AT TIME ZONE 'Europe/Paris' AS heure,\n u.v AS kwh\n FROM (SELECT DISTINCT ON (jour) jour, horaire FROM prevision\n WHERE jour >= current_date ORDER BY jour, publie_a DESC) c,\n unnest(c.horaire) WITH ORDINALITY AS u(v, i)\n WHERE (c.jour + (u.i - 1) * interval '1 hour') AT TIME ZONE 'Europe/Paris'\n > date_trunc('hour', now())\n) p\n WHERE p.heure >= (SELECT d FROM bornes)\n -- Borne STRICTE : une periode qui commence pile a la fin de la fenetre\n -- appartient a la suivante. Sans cela, « La journee » affiche une barre\n -- de demain 00 h.\n AND p.heure < $__timeTo()::timestamptz\n GROUP BY 1\n)\n-- FULL OUTER : une periode a venir n'a AUCUNE ligne de mesure, une periode\n-- passee n'a aucune prevision. Ni l'une ni l'autre ne doit disparaitre.\nSELECT to_char(coalesce(m.b, a.b), (SELECT forme FROM pas)) AS \"Heure\",\n m.\"Solaire\",\n m.\"Reseau\",\n m.\"Maison\",\n m.\"Voiture\",\n round(a.kwh::numeric, 2) AS \"Encore attendu\"\nFROM mesure_par_periode m FULL OUTER JOIN a_venir a ON a.b = m.b\nORDER BY coalesce(m.b, a.b)" } ], "gridPos": { @@ -311,7 +340,27 @@ "axisBorderShow": false } }, - "overrides": [] + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Encore attendu" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#9e9e9e" + } + }, + { + "id": "custom.fillOpacity", + "value": 35 + } + ] + } + ] }, "options": { "orientation": "auto", diff --git a/deploy/historisation/grafana/dashboards/systeme.json b/deploy/historisation/grafana/dashboards/systeme.json index c82fdbb..6b2a937 100644 --- a/deploy/historisation/grafana/dashboards/systeme.json +++ b/deploy/historisation/grafana/dashboards/systeme.json @@ -1245,7 +1245,7 @@ "format": "table", "rawQuery": true, "editorMode": "code", - "rawSql": "WITH bornes AS (\n SELECT $__timeFrom()::timestamptz AS d, $__timeTo()::timestamptz AS f\n),\n-- Le pas s'adapte a la plage choisie. Sans cela, « 30 derniers jours » rend\n-- 720 barres illisibles — c'est exactement ce que fait VRM en passant de\n-- l'heure au jour puis au mois.\npas AS (\n SELECT CASE WHEN f - d > interval '70 days' THEN 'month'\n WHEN f - d > interval '4 days' THEN 'day'\n ELSE 'hour' END AS unite,\n CASE WHEN f - d > interval '70 days' THEN 'MM/YYYY'\n WHEN f - d > interval '4 days' THEN 'DD/MM'\n ELSE 'DD/MM HH24' END AS forme\n FROM bornes\n),\naxe AS (\n SELECT generate_series(date_trunc('minute', (SELECT d FROM bornes)),\n date_trunc('minute', (SELECT f FROM bornes)),\n interval '1 minute') AS t\n),\nreleve AS (\n SELECT date_trunc('minute', ts) AS t,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_pv_power_system') AS Solaire,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_pv_power_system'))[1] AS d_Solaire,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_grid_power_l1') AS Reseau,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_grid_power_l1'))[1] AS d_Reseau,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS Maison,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1'))[1] AS d_Maison,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power') AS Voiture,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power'))[1] AS d_Voiture\n FROM mesure WHERE $__timeFilter(ts) GROUP BY 1\n),\npar_minute AS (\n SELECT axe.t, releve.Solaire, releve.d_Solaire, releve.Reseau, releve.d_Reseau, releve.Maison, releve.d_Maison, releve.Voiture, releve.d_Voiture\n FROM axe LEFT JOIN releve USING (t)\n),\npaliers AS (\n SELECT t, Solaire, d_Solaire, Reseau, d_Reseau, Maison, d_Maison, Voiture, d_Voiture,\n count(d_Solaire) OVER (ORDER BY t) AS g_Solaire,\n count(d_Reseau) OVER (ORDER BY t) AS g_Reseau,\n count(d_Maison) OVER (ORDER BY t) AS g_Maison,\n count(d_Voiture) OVER (ORDER BY t) AS g_Voiture\n FROM par_minute\n),\nreport AS (\n SELECT t,\n coalesce(Solaire, first_value(d_Solaire) OVER (PARTITION BY g_Solaire ORDER BY t)) AS Solaire,\n coalesce(Reseau, first_value(d_Reseau) OVER (PARTITION BY g_Reseau ORDER BY t)) AS Reseau,\n coalesce(Maison, first_value(d_Maison) OVER (PARTITION BY g_Maison ORDER BY t)) AS Maison,\n coalesce(Voiture, first_value(d_Voiture) OVER (PARTITION BY g_Voiture ORDER BY t)) AS Voiture\n FROM paliers\n)\nSELECT to_char(date_trunc(pas.unite, t), pas.forme) AS \"Heure\",\n round((avg(Solaire) * count(Solaire) / 60.0 / 1000.0)::numeric, 2) AS \"Solaire\",\n round((avg(Reseau) * count(Reseau) / 60.0 / 1000.0)::numeric, 2) AS \"Reseau\",\n round((avg(Maison) * count(Maison) / 60.0 / 1000.0)::numeric, 2) AS \"Maison\",\n round((avg(Voiture) * count(Voiture) / 60.0 / 1000.0)::numeric, 2) AS \"Voiture\"\nFROM report, pas\nGROUP BY date_trunc(pas.unite, t), pas.forme\nHAVING count(Solaire) > 0 OR count(Reseau) > 0 OR count(Maison) > 0 OR count(Voiture) > 0\nORDER BY date_trunc(pas.unite, t)" + "rawSql": "WITH bornes AS (\n -- ⚠️ LA MESURE NE PEUT PAS DEPASSER MAINTENANT. « La journee » s'etend\n -- desormais jusqu'a la fin du jour pour montrer la prevision ; sans ce\n -- garde-fou, le report de la derniere valeur connue (plus bas) remplirait les\n -- heures a venir avec la puissance de l'instant et gonflerait l'energie.\n SELECT $__timeFrom()::timestamptz AS d,\n least($__timeTo()::timestamptz, now()) AS f\n),\n-- Le pas s'adapte a la plage choisie. Sans cela, « 30 derniers jours » rend\n-- 720 barres illisibles — c'est exactement ce que fait VRM en passant de\n-- l'heure au jour puis au mois.\npas AS (\n SELECT CASE WHEN f - d > interval '70 days' THEN 'month'\n WHEN f - d > interval '4 days' THEN 'day'\n ELSE 'hour' END AS unite,\n CASE WHEN f - d > interval '70 days' THEN 'MM/YYYY'\n WHEN f - d > interval '4 days' THEN 'DD/MM'\n ELSE 'DD/MM HH24' END AS forme\n FROM bornes\n),\naxe AS (\n SELECT generate_series(date_trunc('minute', (SELECT d FROM bornes)),\n date_trunc('minute', (SELECT f FROM bornes)),\n interval '1 minute') AS t\n),\nreleve AS (\n SELECT date_trunc('minute', ts) AS t,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_pv_power_system') AS Solaire,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_pv_power_system'))[1] AS d_Solaire,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_grid_power_l1') AS Reseau,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_grid_power_l1'))[1] AS d_Reseau,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS Maison,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1'))[1] AS d_Maison,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power') AS Voiture,\n (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power'))[1] AS d_Voiture\n FROM mesure WHERE $__timeFilter(ts) GROUP BY 1\n),\npar_minute AS (\n SELECT axe.t, releve.Solaire, releve.d_Solaire, releve.Reseau, releve.d_Reseau, releve.Maison, releve.d_Maison, releve.Voiture, releve.d_Voiture\n FROM axe LEFT JOIN releve USING (t)\n),\npaliers AS (\n SELECT t, Solaire, d_Solaire, Reseau, d_Reseau, Maison, d_Maison, Voiture, d_Voiture,\n count(d_Solaire) OVER (ORDER BY t) AS g_Solaire,\n count(d_Reseau) OVER (ORDER BY t) AS g_Reseau,\n count(d_Maison) OVER (ORDER BY t) AS g_Maison,\n count(d_Voiture) OVER (ORDER BY t) AS g_Voiture\n FROM par_minute\n),\nreport AS (\n SELECT t,\n coalesce(Solaire, first_value(d_Solaire) OVER (PARTITION BY g_Solaire ORDER BY t)) AS Solaire,\n coalesce(Reseau, first_value(d_Reseau) OVER (PARTITION BY g_Reseau ORDER BY t)) AS Reseau,\n coalesce(Maison, first_value(d_Maison) OVER (PARTITION BY g_Maison ORDER BY t)) AS Maison,\n coalesce(Voiture, first_value(d_Voiture) OVER (PARTITION BY g_Voiture ORDER BY t)) AS Voiture\n FROM paliers\n),\nmesure_par_periode AS (\n SELECT date_trunc(pas.unite, t) AS b,\n round((avg(Solaire) * count(Solaire) / 60.0 / 1000.0)::numeric, 2) AS \"Solaire\",\n round((avg(Reseau) * count(Reseau) / 60.0 / 1000.0)::numeric, 2) AS \"Reseau\",\n round((avg(Maison) * count(Maison) / 60.0 / 1000.0)::numeric, 2) AS \"Maison\",\n round((avg(Voiture) * count(Voiture) / 60.0 / 1000.0)::numeric, 2) AS \"Voiture\"\n FROM report, pas\n GROUP BY date_trunc(pas.unite, t)\n HAVING count(Solaire) > 0 OR count(Reseau) > 0 OR count(Maison) > 0 OR count(Voiture) > 0\n)\n\nSELECT to_char(b, (SELECT forme FROM pas)) AS \"Heure\",\n \"Solaire\",\n \"Reseau\",\n \"Maison\",\n \"Voiture\"\nFROM mesure_par_periode\nORDER BY b" } ], "gridPos": { diff --git a/deploy/historisation/grafana/dashboards/telephone.json b/deploy/historisation/grafana/dashboards/telephone.json index 0f3e6c4..916adc6 100644 --- a/deploy/historisation/grafana/dashboards/telephone.json +++ b/deploy/historisation/grafana/dashboards/telephone.json @@ -62,7 +62,7 @@ { "type": "stat", "title": "Maintenant", - "description": "Marge = ce que la batterie a, moins ce qu'il lui faut pour tenir jusqu'au relais du solaire. Negative, la nuit ne passe pas sans reseau.", + "description": "Marge = ce que la batterie a, moins ce qu'il lui faut pour tenir jusqu'au relais du solaire. Negative, la nuit ne passe pas sans reseau. « Soleil restant » est le seul chiffre de ce bandeau qui ne soit pas une mesure : c'est la derniere prevision pour les heures qui restent aujourd'hui, l'heure en cours exclue.", "datasource": { "type": "grafana-postgresql-datasource", "uid": "evcs-pg" @@ -77,7 +77,7 @@ "format": "table", "rawQuery": true, "editorMode": "code", - "rawSql": "SELECT\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc'\n ORDER BY ts DESC LIMIT 1) AS \"Batterie\",\n (SELECT soc_pct - soc_requis_pct FROM bilan\n WHERE soc_requis_pct IS NOT NULL ORDER BY ts DESC LIMIT 1) AS \"Marge\",\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_pv_power_system'\n ORDER BY ts DESC LIMIT 1)\n- (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_consumption_power_l1'\n ORDER BY ts DESC LIMIT 1) AS \"Excedent\"" + "rawSql": "SELECT\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc'\n ORDER BY ts DESC LIMIT 1) AS \"Batterie\",\n (SELECT soc_pct - soc_requis_pct FROM bilan\n WHERE soc_requis_pct IS NOT NULL ORDER BY ts DESC LIMIT 1) AS \"Marge\",\n (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_pv_power_system'\n ORDER BY ts DESC LIMIT 1)\n- (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_consumption_power_l1'\n ORDER BY ts DESC LIMIT 1) AS \"Excedent\",\n (SELECT round(coalesce(sum(kwh), 0)::numeric, 1)\n FROM (\n SELECT (c.jour + (u.i - 1) * interval '1 hour') AT TIME ZONE 'Europe/Paris' AS heure,\n u.v AS kwh\n FROM (SELECT DISTINCT ON (jour) jour, horaire FROM prevision\n WHERE jour >= current_date ORDER BY jour, publie_a DESC) c,\n unnest(c.horaire) WITH ORDINALITY AS u(v, i)\n WHERE (c.jour + (u.i - 1) * interval '1 hour') AT TIME ZONE 'Europe/Paris'\n > date_trunc('hour', now())\n) p\n WHERE p.heure < date_trunc('day', now()) + interval '1 day')\n AS \"Soleil restant\"" } ], "gridPos": { @@ -202,6 +202,29 @@ } } ] + }, + { + "matcher": { + "id": "byName", + "options": "Soleil restant" + }, + "properties": [ + { + "id": "unit", + "value": "kwatth" + }, + { + "id": "decimals", + "value": 1 + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#9e9e9e" + } + } + ] } ] }, @@ -226,7 +249,7 @@ { "type": "stat", "title": "La nuit qui vient", - "description": "Energie a couvrir d'ici l'heure ou la production repasse au-dessus de la consommation, et le niveau de batterie qu'il faudrait pour y arriver.", + "description": "Energie a couvrir d'ici l'heure ou la production repasse au-dessus de la consommation, et le niveau de batterie qu'il faudrait pour y arriver. « Soleil demain » dit de combien la batterie pourra se refaire une fois la nuit passee : c'est lui qui decide jusqu'ou on peut la laisser descendre.", "datasource": { "type": "grafana-postgresql-datasource", "uid": "evcs-pg" @@ -241,7 +264,7 @@ "format": "table", "rawQuery": true, "editorMode": "code", - "rawSql": "SELECT round(energie_a_couvrir_kwh::numeric, 1) AS \"A couvrir\",\n round(soc_requis_pct::numeric) AS \"SOC requis\",\n heure_croisement AS \"Relais du solaire\"\nFROM bilan ORDER BY ts DESC LIMIT 1" + "rawSql": "SELECT round(energie_a_couvrir_kwh::numeric, 1) AS \"A couvrir\",\n round(soc_requis_pct::numeric) AS \"SOC requis\",\n heure_croisement AS \"Relais du solaire\",\n (SELECT round(coalesce(sum(kwh), 0)::numeric, 1)\n FROM (\n SELECT (c.jour + (u.i - 1) * interval '1 hour') AT TIME ZONE 'Europe/Paris' AS heure,\n u.v AS kwh\n FROM (SELECT DISTINCT ON (jour) jour, horaire FROM prevision\n WHERE jour >= current_date ORDER BY jour, publie_a DESC) c,\n unnest(c.horaire) WITH ORDINALITY AS u(v, i)\n WHERE (c.jour + (u.i - 1) * interval '1 hour') AT TIME ZONE 'Europe/Paris'\n > date_trunc('hour', now())\n) p\n WHERE p.heure >= date_trunc('day', now()) + interval '1 day')\n AS \"Soleil demain\"\nFROM bilan ORDER BY ts DESC LIMIT 1" } ], "gridPos": { @@ -314,6 +337,29 @@ } } ] + }, + { + "matcher": { + "id": "byName", + "options": "Soleil demain" + }, + "properties": [ + { + "id": "unit", + "value": "kwatth" + }, + { + "id": "decimals", + "value": 1 + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#9e9e9e" + } + } + ] } ] }, diff --git a/tools/gen-grafana-dashboards.py b/tools/gen-grafana-dashboards.py index 9a4857f..3e72bb6 100644 --- a/tools/gen-grafana-dashboards.py +++ b/tools/gen-grafana-dashboards.py @@ -130,7 +130,7 @@ def table(title, targets, ds, x, y, w, h, desc="", overrides=None): "options": {"showHeader": True, "cellHeight": "sm", "footer": {"show": False}}} -def barchart(title, targets, ds, x, y, w, h, unit="", desc=""): +def barchart(title, targets, ds, x, y, w, h, unit="", desc="", overrides=None): return {"type": "barchart", "title": title, "description": desc, "datasource": ds, "targets": targets, "gridPos": {"x": x, "y": y, "w": w, "h": h}, @@ -138,7 +138,7 @@ def barchart(title, targets, ds, x, y, w, h, unit="", desc=""): "color": {"mode": "palette-classic"}, "custom": {"lineWidth": 1, "fillOpacity": 80, "gradientMode": "none", "axisBorderShow": False}}, - "overrides": []}, + "overrides": overrides or []}, "options": {"orientation": "auto", "xTickLabelRotation": -45, "showValue": "never", "stacking": "none", "groupWidth": 0.7, "barWidth": 0.9, @@ -146,13 +146,18 @@ def barchart(title, targets, ds, x, y, w, h, unit="", desc=""): "showLegend": True}, "tooltip": {"mode": "multi", "sort": "none"}}} -def dashboard(uid, title, desc, panels, refresh="1m", frm="now-24h", variables=None): +def dashboard(uid, title, desc, panels, refresh="1m", frm="now-24h", + jusqu="now", variables=None): return { "templating": {"list": variables or []}, "uid": uid, "title": title, "description": desc, "tags": ["evcs"], "timezone": "Europe/Paris", "editable": True, "schemaVersion": 39, "version": 1, "refresh": refresh, - "time": {"from": frm, "to": "now"}, + # `jusqu` depasse « maintenant » sur les tableaux qui montrent une + # prevision : sans cela la partie a venir tombe hors du cadre, et le + # tableau ne montre que du passe. Les requetes de MESURE bornent alors + # elles-memes leur fin a `now()` — voir energie_sql(). + "time": {"from": frm, "to": jusqu}, "timepicker": {"refresh_intervals": ["30s", "1m", "5m", "15m", "1h"]}, "annotations": {"list": [ {"builtIn": 1, "datasource": {"type": "grafana", "uid": "-- Grafana --"}, @@ -179,6 +184,23 @@ def dashboard(uid, title, desc, panels, refresh="1m", frm="now-24h", variables=N "AND genre <> 'bilan' ORDER BY 1"}}]}, "panels": panels} +# La prevision LA PLUS RECENTE, mise a plat en (heure locale, kWh), et bornee +# aux heures STRICTEMENT a venir. +# +# ⚠️ L'heure EN COURS est volontairement exclue : elle est deja mesuree pour ses +# minutes ecoulees. Y superposer une barre de prevision d'heure pleine ferait +# compter deux fois la meme heure et donnerait un total faux. +PREVISION_A_VENIR = """ + SELECT (c.jour + (u.i - 1) * interval '1 hour') AT TIME ZONE 'Europe/Paris' AS heure, + u.v AS kwh + FROM (SELECT DISTINCT ON (jour) jour, horaire FROM prevision + WHERE jour >= current_date ORDER BY jour, publie_a DESC) c, + unnest(c.horaire) WITH ORDINALITY AS u(v, i) + WHERE (c.jour + (u.i - 1) * interval '1 hour') AT TIME ZONE 'Europe/Paris' + > date_trunc('hour', now()) +""" + + # ===================================================================== # 1. LA NUIT — la question a laquelle on ne savait pas repondre le 03/09 # ===================================================================== @@ -541,10 +563,17 @@ telephone = dashboard("evcs-telephone", "Sur le telephone", ( " (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_pv_power_system'\n" " ORDER BY ts DESC LIMIT 1)\n" "- (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_consumption_power_l1'\n" - " ORDER BY ts DESC LIMIT 1) AS \"Excedent\"", fmt="table")], + " ORDER BY ts DESC LIMIT 1) AS \"Excedent\",\n" + " (SELECT round(coalesce(sum(kwh), 0)::numeric, 1)\n" + " FROM (" + PREVISION_A_VENIR + ") p\n" + " WHERE p.heure < date_trunc('day', now()) + interval '1 day')\n" + " AS \"Soleil restant\"", fmt="table")], PG, 0, 0, 24, 5, "Marge = ce que la batterie a, moins ce qu'il lui faut pour tenir jusqu'au " - "relais du solaire. Negative, la nuit ne passe pas sans reseau.", + "relais du solaire. Negative, la nuit ne passe pas sans reseau. " + "« Soleil restant » est le seul chiffre de ce bandeau qui ne soit pas une " + "mesure : c'est la derniere prevision pour les heures qui restent " + "aujourd'hui, l'heure en cours exclue.", overrides=[ sur("Batterie", [{"id": "unit", "value": "percent"}, {"id": "thresholds", "value": seuils( @@ -555,22 +584,37 @@ telephone = dashboard("evcs-telephone", "Sur le telephone", ( sur("Excedent", [{"id": "unit", "value": "watt"}, {"id": "decimals", "value": 0}, {"id": "thresholds", "value": seuils( - ("blue", None), ("green", 0))}])]), + ("blue", None), ("green", 0))}]), + sur("Soleil restant", [{"id": "unit", "value": "kwatth"}, + {"id": "decimals", "value": 1}, + {"id": "color", + "value": {"mode": "fixed", "fixedColor": "#9e9e9e"}}])]), stat("La nuit qui vient", [ sql("A", "SELECT round(energie_a_couvrir_kwh::numeric, 1) AS \"A couvrir\",\n" " round(soc_requis_pct::numeric) AS \"SOC requis\",\n" - " heure_croisement AS \"Relais du solaire\"\n" + " heure_croisement AS \"Relais du solaire\",\n" + " (SELECT round(coalesce(sum(kwh), 0)::numeric, 1)\n" + " FROM (" + PREVISION_A_VENIR + ") p\n" + " WHERE p.heure >= date_trunc('day', now()) + interval '1 day')\n" + " AS \"Soleil demain\"\n" "FROM bilan ORDER BY ts DESC LIMIT 1", fmt="table")], PG, 0, 5, 24, 5, "Energie a couvrir d'ici l'heure ou la production repasse au-dessus de la " - "consommation, et le niveau de batterie qu'il faudrait pour y arriver.", + "consommation, et le niveau de batterie qu'il faudrait pour y arriver. " + "« Soleil demain » dit de combien la batterie pourra se refaire une fois " + "la nuit passee : c'est lui qui decide jusqu'ou on peut la laisser " + "descendre.", overrides=[ sur("A couvrir", [{"id": "unit", "value": "kwatth"}]), sur("SOC requis", [{"id": "unit", "value": "percent"}, {"id": "thresholds", "value": seuils( - ("green", None), ("yellow", 60), ("orange", 85), ("red", 100))}])]), + ("green", None), ("yellow", 60), ("orange", 85), ("red", 100))}]), + sur("Soleil demain", [{"id": "unit", "value": "kwatth"}, + {"id": "decimals", "value": 1}, + {"id": "color", + "value": {"mode": "fixed", "fixedColor": "#9e9e9e"}}])]), ts("Ce qu'elle a, ce qu'il lui faut", [ sql("A", "SELECT ts AS time,\n" @@ -691,8 +735,18 @@ ENERGIE_CLES = [ # D'ou l'axe des minutes COMPLET par `generate_series`, la valeur reportee # depuis le dernier point connu, puis la moyenne. Chaque heure pese alors ses # soixante minutes, qu'il y ait eu une mesure ou non. -ENERGIE_SQL = """WITH bornes AS ( - SELECT $__timeFrom()::timestamptz AS d, $__timeTo()::timestamptz AS f + + +def energie_sql(prevision=False): + """Energie par periode. Avec `prevision`, une colonne « Encore attendu » + prolonge le graphique sur les heures a venir.""" + return """WITH bornes AS ( + -- ⚠️ LA MESURE NE PEUT PAS DEPASSER MAINTENANT. « La journee » s'etend + -- desormais jusqu'a la fin du jour pour montrer la prevision ; sans ce + -- garde-fou, le report de la derniere valeur connue (plus bas) remplirait les + -- heures a venir avec la puissance de l'instant et gonflerait l'energie. + SELECT $__timeFrom()::timestamptz AS d, + least($__timeTo()::timestamptz, now()) AS f ), -- Le pas s'adapte a la plage choisie. Sans cela, « 30 derniers jours » rend -- 720 barres illisibles — c'est exactement ce que fait VRM en passant de @@ -736,16 +790,43 @@ report AS ( f" coalesce({n}, first_value(d_{n}) OVER (PARTITION BY g_{n} ORDER BY t)) AS {n}" for _, n in ENERGIE_CLES) + """ FROM paliers -) -SELECT to_char(date_trunc(pas.unite, t), pas.forme) AS "Heure", +), +mesure_par_periode AS ( + SELECT date_trunc(pas.unite, t) AS b, """ + ",\n".join( # Energie REELLEMENT accumulee : puissance moyenne x duree observee. - f' round((avg({n}) * count({n}) / 60.0 / 1000.0)::numeric, 2) AS "{n}"' + f' round((avg({n}) * count({n}) / 60.0 / 1000.0)::numeric, 2) AS "{n}"' for _, n in ENERGIE_CLES) + """ -FROM report, pas -GROUP BY date_trunc(pas.unite, t), pas.forme -HAVING """ + " OR ".join(f"count({n}) > 0" for _, n in ENERGIE_CLES) + """ -ORDER BY date_trunc(pas.unite, t)""" + FROM report, pas + GROUP BY date_trunc(pas.unite, t) + HAVING """ + " OR ".join(f"count({n}) > 0" for _, n in ENERGIE_CLES) + """ +) +""" + (""" +, a_venir AS ( + SELECT date_trunc((SELECT unite FROM pas), p.heure) AS b, + sum(p.kwh) AS kwh + FROM (""" + PREVISION_A_VENIR + """) p + WHERE p.heure >= (SELECT d FROM bornes) + -- Borne STRICTE : une periode qui commence pile a la fin de la fenetre + -- appartient a la suivante. Sans cela, « La journee » affiche une barre + -- de demain 00 h. + AND p.heure < $__timeTo()::timestamptz + GROUP BY 1 +) +-- FULL OUTER : une periode a venir n'a AUCUNE ligne de mesure, une periode +-- passee n'a aucune prevision. Ni l'une ni l'autre ne doit disparaitre. +SELECT to_char(coalesce(m.b, a.b), (SELECT forme FROM pas)) AS "Heure", +""" + ",\n".join(f' m."{n}"' for _, n in ENERGIE_CLES) + """, + round(a.kwh::numeric, 2) AS "Encore attendu" +FROM mesure_par_periode m FULL OUTER JOIN a_venir a ON a.b = m.b +ORDER BY coalesce(m.b, a.b)""" if prevision else """ +SELECT to_char(b, (SELECT forme FROM pas)) AS "Heure", +""" + ",\n".join(f' "{n}"' for _, n in ENERGIE_CLES) + """ +FROM mesure_par_periode +ORDER BY b""") + + +ENERGIE_SQL = energie_sql() # Detail par tracker. Les quatre entrees du RS 450/200 et le MPPT1 ne suivent # pas la meme convention de nommage : deux branches, reunies puis pivotees. @@ -1296,7 +1377,12 @@ BILAN_TOTAUX = BILAN_BASE + """SELECT round((sum(greatest(batterie, 0)) / 60.0 / 1000.0)::numeric, 1) AS "Batterie chargee", round((sum(greatest(-batterie, 0)) / 60.0 / 1000.0)::numeric, 1) AS "Batterie rendue", round((100.0 * greatest(sum(maison) - sum(reseau), 0) - / nullif(sum(maison), 0))::numeric, 0) AS "Autonomie" + / nullif(sum(maison), 0))::numeric, 0) AS "Autonomie", + -- Ce qui reste a venir aujourd'hui, a cote de ce qui est deja fait. Sous- + -- requete independante de `report` : elle ne touche a aucun total mesure. + (SELECT round(coalesce(sum(kwh), 0)::numeric, 1) + FROM (""" + PREVISION_A_VENIR + """) p + WHERE p.heure < date_trunc('day', now()) + interval '1 day') AS "Reste attendu" FROM report GROUP BY (1 = 1)""" BILAN_FLUX = BILAN_BASE + """SELECT t AS time, @@ -1318,7 +1404,9 @@ journee = dashboard( desc="Une ligne d'en-tetes, une ligne de valeurs. L'autonomie est la part " "de la consommation de la maison qui n'est PAS venue du reseau. " "« Batterie rendue » est ce qu'elle a restitue, donc du solaire " - "differe et non une consommation supplementaire.", + "differe et non une consommation supplementaire. « Reste attendu » " + "est la seule colonne qui ne soit pas une mesure : c'est ce que la " + "derniere prevision annonce pour les heures qui restent.", overrides=[sur(n, [{"id": "unit", "value": "kwatth"}, {"id": "decimals", "value": 1}]) for n in ("Solaire produit", "Maison consommee", "Voiture", @@ -1330,6 +1418,13 @@ journee = dashboard( {"id": "thresholds", "value": seuils( ("green", None), ("#EAB839", 3), ("orange", 8))}]), + sur("Reste attendu", [{"id": "unit", "value": "kwatth"}, + {"id": "decimals", "value": 1}, + {"id": "custom.cellOptions", + "value": {"type": "color-text"}}, + {"id": "color", + "value": {"mode": "fixed", + "fixedColor": "#9e9e9e"}}]), sur("Autonomie", [{"id": "unit", "value": "percent"}, {"id": "decimals", "value": 0}, {"id": "custom.cellOptions", @@ -1342,10 +1437,19 @@ journee = dashboard( # nommees par la variable $vue de « Le systeme ». Sur un tableau qui n'a pas # cette variable, elle filtre TOUT et Grafana affiche « No numeric fields # found ». Ici on veut les quatre colonnes, tout le temps. - barchart("Energie heure par heure", [sql("A", ENERGIE_SQL, fmt="table")], + barchart("Energie heure par heure", + [sql("A", energie_sql(prevision=True), fmt="table")], PG, 0, 5, 24, 9, "kwatth", - "La forme de la journee : quand le solaire a couvert la maison, et quand " - "il a fallu acheter. Le pas suit la periode choisie."), + "La forme de la journee : quand le solaire a couvert la maison, quand il " + "a fallu acheter — et, a la suite des barres mesurees, ce que la derniere " + "prevision annonce pour les heures qui restent. L'heure EN COURS n'a pas " + "de barre de prevision : elle est deja mesuree pour ses minutes ecoulees.", + overrides=[ + # Gris clair et translucide : ce n'est pas une mesure, et le coup d'oeil + # doit le dire avant la legende. + sur("Encore attendu", [ + {"id": "color", "value": {"mode": "fixed", "fixedColor": "#9e9e9e"}}, + {"id": "custom.fillOpacity", "value": 35}])]), ts("Les flux de la journee", [sql("A", BILAN_FLUX)], PG, 0, 14, 24, 10, "watt", @@ -1405,7 +1509,7 @@ journee = dashboard( desc="Le genre `bilan` est ecarte : ses bascules de production bridee " "changent plusieurs fois par heure et noieraient le reste. Elles " "sont lisibles dans la frise ci-dessus."), -], frm="now/d") +], frm="now/d", jusqu="now/d") base = pathlib.Path('deploy/historisation/grafana/dashboards')