La prevision entre dans « La journee » et sur le telephone

Suite du correctif de l onglet Home Assistant. « La journee » avait le meme
angle mort : cadre sur le jour civil, il s arretait a l heure en cours.

Fenetre portee a la journee ENTIERE (dashboard() gagne un parametre `jusqu`),
serie « Encore attendu » en gris translucide a la suite des barres mesurees,
et colonne « Reste attendu » dans le bandeau du haut, a cote de « Solaire
produit ».

Sur le telephone, aucun panneau ajoute — un panneau de plus y est un ecran de
plus a faire defiler. « Soleil restant » rejoint le bandeau Maintenant, a cote
de Excedent ; « Soleil demain » rejoint La nuit qui vient, puisque c est lui
qui decide jusqu ou la batterie peut descendre.

Trois pieges, tous dans la meme direction. La mesure ne doit pas suivre la
fenetre dans le futur : ENERGIE_SQL construisait son axe entre les deux macros
puis reportait la derniere valeur connue, ce qui 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()). FULL OUTER JOIN et non LEFT :
une periode a venir n a aucune ligne de mesure. Et borne haute stricte, sans
quoi une barre « 18/09 00 h » apparaissait dans La journee.

L heure EN COURS n a volontairement pas de barre de prevision : elle est deja
mesuree pour ses minutes ecoulees.

73/73 requetes valides — mais ce controle prouve l execution, pas la grandeur.
Les deux requetes de La journee ont donc ete rejouees a la main avec la vraie
fenetre : 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 troisieme piege.

Corrige au passage la note du 08/09 sur l acces a l API Grafana : les
apostrophes du .env sont SIMPLES et non doubles, verifie a l octet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
This commit is contained in:
Tallagrand Thierry
2026-09-17 02:37:43 +02:00
parent a6baad54dc
commit 1541ccf7b4
5 changed files with 316 additions and 37 deletions

View File

@@ -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*

File diff suppressed because one or more lines are too long

View File

@@ -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": {

View File

@@ -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"
}
}
]
}
]
},

View File

@@ -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')