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

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