Files
EVCS/tools/gen-grafana-dashboards.py
Tallagrand Thierry 686c2bad57 Le bilan tire ses bornes des donnees, plus des macros de temps
Le panneau rendait sept « — » alors que sa requete donnait les bons totaux en
psql, avec la substitution exacte que Grafana applique, et que le serveur ne
journalisait aucune erreur : une ligne de NULL, donc un axe de minutes vide
cote Grafana.

Les bornes de generate_series venaient de $__timeFrom() et $__timeTo(). Elles
viennent desormais du min et du max de la table sur la fenetre, et la seule
macro restante est $__timeFilter(ts) — exactement la forme du panneau « Sur la
periode » des vehicules, qui lui a toujours fonctionne.

C'est aussi plus juste physiquement : on n'extrapole plus au-dela de la
derniere mesure observee.

Note d'exploitation : le GRAFANA_ADMIN_PASSWORD du .env n'est pas celui en
service. Grafana n'applique cette variable qu'a la creation initiale de
l'admin, jamais a un compte deja existant — l'API refuse donc ce mot de passe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
2026-09-07 23:49:47 +02:00

1400 lines
76 KiB
Python

# -*- coding: utf-8 -*-
"""Genere les tableaux de bord EVCS. Le JSON de Grafana est verbeux ; l'ecrire
a la main invite les fautes silencieuses. Ce generateur est rejouable."""
import json, pathlib
PG = {"type": "grafana-postgresql-datasource", "uid": "evcs-pg"}
IN = {"type": "influxdb", "uid": "evcs-influx"}
def sql(refid, q, fmt="time_series"):
return {"refId": refid, "datasource": PG, "format": fmt,
"rawQuery": True, "editorMode": "code", "rawSql": q}
def flux(refid, q):
return {"refId": refid, "datasource": IN, "query": q}
def sql_grandeurs(refid, colonnes, calculees=None, fmt="time_series"):
"""Rapproche plusieurs grandeurs de la table `mesure` sur un axe commun.
Deux difficultes, toutes deux resolues ici :
1. Le service horodate chaque mesure avec son propre `last_updated` — le
vrai instant de la mesure, ce qui est le bon choix. Mais deux grandeurs
ne partagent alors presque jamais le meme `ts` : un `GROUP BY ts` rend
une ligne par grandeur, avec une seule colonne remplie.
2. Le service n'ecrit une mesure que si elle a change. Un simple
regroupement a la minute laisse donc des trous, et toute colonne
CALCULEE (une difference, par exemple) n'existe que les minutes ou
toutes ses entrees ont bouge — 73 minutes sur 434 le 04/09.
D'ou le report de la derniere valeur connue, qui est aussi ce que fait le
controleur en temps reel : il decide sur la derniere valeur qu'il a lue.
L'astuce `count(x) OVER (ORDER BY t)` numerote les paliers entre deux
valeurs non nulles ; `first_value` reprend celle du debut du palier.
"""
noms = [n for _, n in colonnes]
par_minute = ",\n".join(
f" avg(valeur) FILTER (WHERE cle = '{c}') AS {n},\n"
f" (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = '{c}'))[1]"
f" AS d_{n}" for c, n in colonnes)
groupes = ",\n".join(
f" {n}, count(d_{n}) OVER (ORDER BY t) AS g_{n}" for n in noms)
remplis = ",\n".join(
f" coalesce({n}, first_value(d_{n}) OVER (PARTITION BY g_{n} ORDER BY t))"
f" AS {n}" for n in noms)
sorties = ",\n".join(f' {n} AS "{n.replace("_", " ")}"' for n in noms)
if calculees:
sorties += ",\n" + ",\n".join(f' {e} AS "{lib}"' for e, lib in calculees)
q = (f"WITH par_minute AS (\n"
f" SELECT date_trunc('minute', ts) AS t,\n{par_minute}\n"
f" FROM mesure WHERE $__timeFilter(ts) GROUP BY 1\n"
f"),\npaliers AS (\n SELECT t,\n{groupes},\n"
+ ",\n".join(f" d_{n}" for n in noms) + f"\n FROM par_minute\n"
f"),\nreport AS (\n SELECT t,\n{remplis}\n FROM paliers\n)\n"
f"SELECT t AS time,\n{sorties}\nFROM report ORDER BY t")
return sql(refid, q, fmt=fmt)
def ts(title, targets, ds, x, y, w, h, unit="", desc="", extra=None, legend="bottom"):
p = {"type": "timeseries", "title": title, "description": desc,
"datasource": ds, "targets": targets,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"fieldConfig": {"defaults": {
"unit": unit, "color": {"mode": "palette-classic"},
"custom": {"lineWidth": 2, "fillOpacity": 8, "showPoints": "never",
"spanNulls": True, "axisBorderShow": False,
"gradientMode": "opacity"}},
"overrides": []},
"options": {"legend": {"displayMode": "list", "placement": legend,
"showLegend": True, "calcs": []},
"tooltip": {"mode": "multi", "sort": "desc"}}}
if extra:
for k, v in extra.items():
if k == "defaults":
p["fieldConfig"]["defaults"].update(v)
elif k == "overrides":
p["fieldConfig"]["overrides"] = v
else:
p[k] = v
return p
def stat(title, targets, ds, x, y, w, h, desc="", overrides=None, texte="auto",
taille=None, mappings=None, champs=""):
"""Grands chiffres. Sur telephone, Grafana empile TOUS les panneaux en
pleine largeur sous ~770 px : mettre plusieurs valeurs dans UN panneau est
donc le seul moyen de les garder cote a cote et de limiter le defilement."""
return {"type": "stat", "title": title, "description": desc,
"datasource": ds, "targets": targets,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"fieldConfig": {"defaults": {
"color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute",
"steps": [{"color": "text", "value": None}]},
"mappings": mappings or []}, "overrides": overrides or []},
"options": {"reduceOptions": {"calcs": ["lastNotNull"],
# "" = champs numeriques seulement.
# Les etats d'alarme sont du TEXTE :
# il faut "/.*/" pour les afficher.
"fields": champs, "values": False},
"orientation": "horizontal", "textMode": texte,
"colorMode": "value", "graphMode": "none",
"justifyMode": "center",
"percentChangeColorMode": "standard",
"noValue": "—",
**({"text": taille} if taille else {}),
"wideLayout": True}}
def timeline(title, targets, ds, x, y, w, h, desc="", mappings=None):
return {"type": "state-timeline", "title": title, "description": desc,
"datasource": ds, "targets": targets,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"fieldConfig": {"defaults": {
"color": {"mode": "continuous-GrYlRd"},
"custom": {"lineWidth": 0, "fillOpacity": 80,
"insertNulls": False, "spanNulls": True},
"mappings": mappings or []}, "overrides": []},
"options": {"showValue": "auto", "mergeValues": True,
"alignValue": "left", "rowHeight": 0.85,
"legend": {"displayMode": "list", "placement": "bottom",
"showLegend": False},
"tooltip": {"mode": "single"}}}
def table(title, targets, ds, x, y, w, h, desc="", overrides=None):
return {"type": "table", "title": title, "description": desc,
"datasource": ds, "targets": targets,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"fieldConfig": {"defaults": {"custom": {"align": "left",
"cellOptions": {"type": "auto"}, "filterable": True}},
"overrides": overrides or []},
"options": {"showHeader": True, "cellHeight": "sm",
"footer": {"show": False}}}
def barchart(title, targets, ds, x, y, w, h, unit="", desc=""):
return {"type": "barchart", "title": title, "description": desc,
"datasource": ds, "targets": targets,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"fieldConfig": {"defaults": {"unit": unit,
"color": {"mode": "palette-classic"},
"custom": {"lineWidth": 1, "fillOpacity": 80,
"gradientMode": "none", "axisBorderShow": False}},
"overrides": []},
"options": {"orientation": "auto", "xTickLabelRotation": -45,
"showValue": "never", "stacking": "none",
"groupWidth": 0.7, "barWidth": 0.9,
"legend": {"displayMode": "list", "placement": "bottom",
"showLegend": True},
"tooltip": {"mode": "multi", "sort": "none"}}}
def dashboard(uid, title, desc, panels, refresh="1m", frm="now-24h", 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"},
"timepicker": {"refresh_intervals": ["30s", "1m", "5m", "15m", "1h"]},
"annotations": {"list": [
{"builtIn": 1, "datasource": {"type": "grafana", "uid": "-- Grafana --"},
"enable": True, "hide": True, "name": "Annotations & Alerts",
"iconColor": "rgba(0, 211, 255, 1)", "type": "dashboard"},
# Les evenements sont OPT-IN : `enable: False`. Ils rendent un
# graphique illisible des qu'ils sont nombreux — le 06/09, sept
# jours d'historique donnaient une foret de pointillies orange qui
# masquait les courbes. Le bouton « Evenements » en tete de tableau
# les rallume quand on veut le contexte d'un incident.
#
# Et meme allumes, on ecarte le genre `bilan` : a lui seul 26 % des
# lignes, presque toutes des bascules de « production bridee » qui
# changent plusieurs fois par heure. Ce qui merite un trait vertical
# est ce qui ARRIVE — une bascule du contacteur, un vehicule
# branche, un changement de mode de charge, un redemarrage, une
# intervention manuelle — pas un drapeau qui clignote.
{"name": "Evenements", "datasource": PG, "enable": False,
"iconColor": "semi-dark-orange", "hide": False,
"target": {"format": "table", "rawQuery": True, "editorMode": "code",
"rawSql": "SELECT ts AS time, resume AS text, "
"genre || ',' || coalesce(source,'?') AS tags "
"FROM evenement WHERE $__timeFilter(ts) "
"AND genre <> 'bilan' ORDER BY 1"}}]},
"panels": panels}
# =====================================================================
# 1. LA NUIT — la question a laquelle on ne savait pas repondre le 03/09
# =====================================================================
nuit = dashboard("evcs-nuit", "La nuit", (
"Ce que le bilan exigeait, contre ce que la batterie a reellement fait. "
"C'est la question qui a demande une nuit d'archeologie le 03/09 et qui "
"tient maintenant en un coup d'oeil."), [
ts("Couverture de la nuit", [
sql("A", "SELECT ts AS time,\n"
" soc_pct AS \"SOC reel\",\n"
" soc_requis_pct AS \"SOC requis (bilan)\"\n"
"FROM bilan\n"
"WHERE $__timeFilter(ts)\n"
"ORDER BY 1"),
sql("B", "SELECT ts AS time, valeur AS \"Cible du matin\"\n"
"FROM mesure\n"
"WHERE cle = 'input_number.victron_evcs_night_target_soc'\n"
" AND $__timeFilter(ts)\n"
"ORDER BY 1")],
PG, 0, 0, 16, 9, "percent",
"SOC requis = ce qu'il faut MAINTENANT pour tenir jusqu'au croisement "
"PV/conso. Il decroit avec la nuit qui se consomme. Quand la courbe reelle "
"passe dessous, la batterie ne passera pas la nuit sans reseau.",
extra={"defaults": {"min": 0, "max": 100},
"overrides": [
{"matcher": {"id": "byName", "options": "SOC reel"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}},
{"id": "custom.lineWidth", "value": 3}]},
{"matcher": {"id": "byName", "options": "SOC requis (bilan)"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "orange"}},
{"id": "custom.fillOpacity", "value": 0}]},
{"matcher": {"id": "byName", "options": "Cible du matin"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "purple"}},
{"id": "custom.fillOpacity", "value": 0},
{"id": "custom.lineStyle",
"value": {"fill": "dash", "dash": [8, 6]}}]}]}),
ts("Marge (reel moins requis)", [
sql("A", "SELECT ts AS time, soc_pct - soc_requis_pct AS \"Marge\"\n"
"FROM bilan\n"
"WHERE $__timeFilter(ts) AND soc_requis_pct IS NOT NULL\n"
"ORDER BY 1")],
PG, 16, 0, 8, 9, "percent",
"Sous zero, il manque de l'energie pour finir la nuit. La nuit du "
"02-03/09 est restee negative de bout en bout, et personne ne le voyait.",
extra={"defaults": {
"color": {"mode": "thresholds"},
"custom": {"lineWidth": 2, "fillOpacity": 25, "showPoints": "never",
"gradientMode": "scheme", "spanNulls": True,
"thresholdsStyle": {"mode": "line"}},
"thresholds": {"mode": "absolute", "steps": [
{"color": "red", "value": None}, {"color": "green", "value": 0}]}},
"options": {"legend": {"showLegend": False},
"tooltip": {"mode": "single", "sort": "none"}}}),
timeline("Ce que le controleur a decide", [
sql("A", "SELECT ts AS time,\n"
" source_mode AS \"Source\",\n"
" etat AS \"Etat\",\n"
" CASE WHEN session_engagee THEN 'engagee' ELSE '-' END AS \"Session reseau\",\n"
" CASE WHEN takeover_reseau THEN 'demande' ELSE '-' END AS \"Reprise reseau\"\n"
"FROM decision\n"
"WHERE $__timeFilter(ts)\n"
"ORDER BY 1", fmt="table")],
PG, 0, 9, 24, 7,
"Une ligne par CHANGEMENT de decision, pas une toutes les 30 s. "
"La session reseau de nuit doit s'engager UNE fois et se relacher a la "
"fermeture de la fenetre : plusieurs bascules = un oscillateur.",
mappings=[{"type": "value", "options": {
"-": {"color": "dark-blue", "index": 0},
"engagee": {"color": "orange", "index": 1},
"demande": {"color": "semi-dark-orange", "index": 2}}}]),
ts("Les puissances de la nuit", [
sql_grandeurs("A", [
('sensor.victron_grid_power_l1', 'Reseau'),
('sensor.victron_battery_power', 'Batterie'),
('sensor.victron_consumption_power_l1', 'Maison'),
('sensor.victron_evcs_ac_power', 'Voiture')])],
PG, 0, 16, 12, 8, "watt",
"Batterie positive = elle se charge. Contacteur ferme avec un import qui "
"ne couvre pas la maison = la batterie paie quand meme : c'est le defaut "
"du 04/09 (consigne retombee a 50 W en pleine session).",
extra={"overrides": [
{"matcher": {"id": "byName", "options": "Reseau"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}]},
{"matcher": {"id": "byName", "options": "Batterie"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}]},
{"matcher": {"id": "byName", "options": "Maison"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "text"}},
{"id": "custom.fillOpacity", "value": 0}]},
{"matcher": {"id": "byName", "options": "Voiture"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "blue"}}]}]}),
ts("Ce qu'il reste a couvrir", [
sql("A", "SELECT ts AS time,\n"
" energie_a_couvrir_kwh AS \"A couvrir d'ici le croisement\",\n"
" reseau_energie_kwh AS \"Dont reseau necessaire\",\n"
" manque_stockage_kwh AS \"Manque de stockage\"\n"
"FROM bilan\n"
"WHERE $__timeFilter(ts)\n"
"ORDER BY 1")],
PG, 12, 16, 12, 8, "kwatth",
"\"Manque de stockage\" non nul = meme pleine, la batterie actuelle ne "
"passe pas. C'est le signal de dimensionnement pour l'extension prevue "
"cet hiver, pas une erreur.",
extra={"defaults": {"custom": {"lineWidth": 2, "fillOpacity": 12,
"showPoints": "never", "spanNulls": True,
"gradientMode": "opacity"}}}),
table("Journal des evenements", [
sql("A", "SELECT ts AS \"Quand\", genre AS \"Genre\", source AS \"Par\",\n"
" resume AS \"Ce qui s'est passe\"\n"
"FROM evenement\n"
"WHERE $__timeFilter(ts)\n"
"ORDER BY ts DESC", fmt="table")],
PG, 0, 24, 24, 8,
"Une courbe sans ses evenements ne s'interprete pas. Ces lignes "
"apparaissent aussi en marqueurs verticaux sur tous les graphiques.",
overrides=[{"matcher": {"id": "byName", "options": "Quand"},
"properties": [{"id": "custom.width", "value": 190}]},
{"matcher": {"id": "byName", "options": "Genre"},
"properties": [{"id": "custom.width", "value": 140},
{"id": "custom.cellOptions",
"value": {"type": "color-text"}},
{"id": "mappings", "value": [{"type": "value", "options": {
"deploiement": {"color": "blue", "index": 0},
"incident": {"color": "red", "index": 1},
"intervention": {"color": "orange", "index": 2},
"systeme": {"color": "purple", "index": 3},
"reseau": {"color": "yellow", "index": 4},
"vehicule": {"color": "green", "index": 5}}}]}]},
{"matcher": {"id": "byName", "options": "Par"},
"properties": [{"id": "custom.width", "value": 130}]}]),
])
# =====================================================================
# 2. LE SOLAIRE — excedent, censure, et ce que les chargeurs disent
# =====================================================================
solaire = dashboard("evcs-solaire", "Le solaire", (
"La production mesuree n'est jamais la production disponible : sans "
"injection, le MPPT ne produit que ce que le systeme peut absorber. "
"Ces panneaux montrent l'ecart entre les deux."), [
ts("Production, maison, et ce qui reste", [
sql_grandeurs("A", [
('sensor.victron_pv_power_system', 'Production'),
('sensor.victron_consumption_power_l1', 'Maison'),
('sensor.victron_evcs_ac_power', 'Voiture')],
calculees=[('Production - Maison', 'Excedent')])],
PG, 0, 0, 24, 9, "watt",
"L'excedent (production moins maison hors voiture) est la grandeur qui "
"decide d'un demarrage depuis le 03/09. Le 03/09 a 19:44, la production "
"valait 758 W — au-dessus du seuil — pour un excedent de MOINS 444 W : "
"la voiture a vide la batterie.",
extra={"overrides": [
{"matcher": {"id": "byName", "options": "Production"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "yellow"}},
{"id": "custom.fillOpacity", "value": 22}]},
{"matcher": {"id": "byName", "options": "Maison"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "text"}},
{"id": "custom.fillOpacity", "value": 0}]},
{"matcher": {"id": "byName", "options": "Voiture"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "blue"}}]},
{"matcher": {"id": "byName", "options": "Excedent"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}},
{"id": "custom.lineWidth", "value": 3},
{"id": "custom.fillOpacity", "value": 0}]}]}),
timeline("Production bridee ?", [
sql("A", "SELECT ts AS time,\n"
" CASE WHEN pv_censure THEN coalesce(motif_censure, 'bridee')\n"
" ELSE 'libre' END AS \"Production\"\n"
"FROM bilan\n"
"WHERE $__timeFilter(ts)\n"
"ORDER BY 1", fmt="table")],
PG, 0, 9, 12, 6,
"Bridee = de la ressource solaire est REFUSEE faute de preneur. "
"Charger la voiture pendant ces heures-la ne coute rien.",
mappings=[{"type": "value", "options": {
"libre": {"color": "green", "index": 0}}},
{"type": "regex", "options": {"pattern": ".*", "result": {"color": "orange", "index": 1}}}]),
timeline("Ce que les chargeurs declarent eux-memes", [
flux("A",
'from(bucket: "evcs")\n'
' |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n'
' |> filter(fn: (r) => r._field == "state")\n'
' |> filter(fn: (r) => r._measurement =~ /operation_mode$/)\n'
' |> aggregateWindow(every: v.windowPeriod, fn: last, createEmpty: false)\n'
' |> map(fn: (r) => ({ r with tracker:\n'
' if r._measurement =~ /solar_1/ then "Sud 1"\n'
' else if r._measurement =~ /solar_2/ then "Sud 2"\n'
' else if r._measurement =~ /solar_3/ then "Sud 3"\n'
' else if r._measurement =~ /solar_4/ then "Ouest"\n'
' else if r._measurement =~ /mppt1/ then "Sud-est"\n'
' else "Ensemble" }))\n'
' |> keep(columns: ["_time", "_value", "tracker"])\n'
' |> pivot(rowKey: ["_time"], columnKey: ["tracker"], valueColumn: "_value")')],
IN, 12, 9, 12, 6,
"mppt_active = ce chargeur donne tout ce qu'il peut. "
"voltage_current_limited ou off = il se retient, faute de preneur. "
"Le 03/09 ces capteurs ont bascule 24 minutes avant le drapeau du "
"bilan — c'est le signal le plus direct dont on dispose.",
mappings=[{"type": "value", "options": {
"mppt_active": {"color": "green", "index": 0},
"voltage_current_limited": {"color": "orange", "index": 1},
"off": {"color": "dark-blue", "index": 2}}}]),
ts("Batterie", [
sql("A", "SELECT date_trunc('minute', ts) AS time, avg(valeur) AS \"SOC\"\n"
"FROM mesure WHERE cle = 'sensor.victron_battery_soc'\n"
" AND $__timeFilter(ts) GROUP BY 1 ORDER BY 1"),
sql("B", "SELECT date_trunc('minute', ts) AS time, avg(valeur) AS \"Courant\"\n"
"FROM mesure WHERE cle = 'sensor.victron_battery_current'\n"
" AND $__timeFilter(ts) GROUP BY 1 ORDER BY 1")],
PG, 0, 15, 12, 8, "percent",
"Le SOC n'est pas lineaire : 0,169 kWh par point au-dessus de 33 %, "
"0,097 en dessous (576 paliers mesures). Un point en bas d'echelle vaut "
"la moitie d'un point en haut.",
extra={"defaults": {"min": 0, "max": 100},
"overrides": [
{"matcher": {"id": "byName", "options": "Courant"},
"properties": [{"id": "unit", "value": "amp"},
{"id": "custom.axisPlacement", "value": "right"},
{"id": "max", "value": None}, {"id": "min", "value": None},
{"id": "color", "value": {"mode": "fixed", "fixedColor": "purple"}}]},
{"matcher": {"id": "byName", "options": "SOC"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}},
{"id": "custom.lineWidth", "value": 3}]}]}),
ts("Le detail que PostgreSQL ne voit pas (~1 point/s)", [
flux("A",
'from(bucket: "evcs")\n'
' |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n'
' |> filter(fn: (r) => r._measurement == "W" and r._field == "value")\n'
' |> filter(fn: (r) => r.entity_id == "victron_pv_power_system"\n'
' or r.entity_id == "victron_consumption_power_l1"\n'
' or r.entity_id == "victron_evcs_ac_power"\n'
' or r.entity_id == "victron_grid_power_l1")\n'
' |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n'
' |> keep(columns: ["_time", "_value", "entity_id"])\n'
' |> pivot(rowKey: ["_time"], columnKey: ["entity_id"], valueColumn: "_value")')],
IN, 12, 15, 12, 8, "watt",
"InfluxDB enregistre chaque changement d'etat, pas un echantillon par "
"minute. C'est la resolution qu'il faut pour voir un transitoire de "
"reprise du reseau (~150 s) ou une pointe de cuisson.",
extra={"defaults": {"custom": {"lineWidth": 1, "fillOpacity": 5,
"showPoints": "never", "spanNulls": True}}}),
])
# =====================================================================
# 3. PREVISION CONTRE REEL — le biais de forme, question ouverte
# =====================================================================
prev_sql = """WITH du_matin AS (
SELECT DISTINCT ON (jour) jour, publie_a, horaire
FROM prevision
WHERE publie_a::date = jour
AND jour BETWEEN $__timeFrom()::date AND $__timeTo()::date
ORDER BY jour, publie_a ASC
),
prevu AS (
SELECT (jour + (h - 1) * interval '1 hour') AS heure,
horaire[h] AS kwh
FROM du_matin, generate_subscripts(horaire, 1) AS h
),
-- INTEGRATION PONDEREE PAR LA DUREE, et non somme/60.
-- Le service n'ecrit une mesure que lorsqu'elle change : compter les
-- echantillons revient a supposer qu'il y en a 60 par heure alors qu'il y
-- en a eu 26 a 47 le 04/09 : l'energie etait sous-estimee d'environ 9 %.
-- Chaque point vaut donc son intervalle jusqu'au suivant, plafonne a 300 s
-- pour qu'un arret du service n'extrapole pas une valeur perimee.
pas AS (
SELECT ts, valeur,
LEAST(EXTRACT(epoch FROM (lead(ts) OVER (ORDER BY ts) - ts)), 300) AS dt
FROM mesure
WHERE cle = 'sensor.victron_pv_power_system' AND $__timeFilter(ts)
),
produit AS (
SELECT date_trunc('hour', ts) AS heure,
sum(valeur * coalesce(dt, 0)) / 3600.0 / 1000.0 AS kwh
FROM pas GROUP BY 1
)
SELECT to_char(coalesce(prevu.heure, produit.heure), 'DD/MM HH24') || ' h' AS "Heure",
round(prevu.kwh::numeric, 2) AS "Prevu le matin meme",
round(produit.kwh::numeric, 2) AS "Reellement produit"
FROM prevu FULL OUTER JOIN produit USING (heure)
WHERE coalesce(prevu.heure, produit.heure) BETWEEN $__timeFrom() AND $__timeTo()
ORDER BY coalesce(prevu.heure, produit.heure)"""
prevision = dashboard("evcs-prevision", "Prevision contre reel", (
"Le total de la prevision tombe juste ; sa FORME non. Sur les trois jours "
"verifies le 04/09, la production arrive plus tot que prevu et "
"l'apres-midi est sur-promis d'un facteur 2 a 3 apres 16 h. Ce tableau "
"existe pour instruire cette question sur des semaines, pas sur trois "
"jours."), [
barchart("Heure par heure : promis contre livre",
[sql("A", prev_sql, fmt="table")],
PG, 0, 0, 24, 10, "kwatth",
"ATTENTION a l'interpretation : \"produit\" est une mesure CENSUREE. "
"Moins que prevu peut vouloir dire mauvaise prevision OU absence de "
"preneur. Lire la bande de censure juste dessous avant de conclure."),
timeline("Y avait-il un preneur ?", [
sql("A", "SELECT ts AS time,\n"
" CASE WHEN pv_censure THEN 'bridee (mesure inexploitable)'\n"
" ELSE 'libre (mesure valide)' END AS \"Production\"\n"
"FROM bilan WHERE $__timeFilter(ts) ORDER BY 1", fmt="table")],
PG, 0, 10, 24, 5,
"Seules les heures 'libre' permettent de juger la prevision. "
"Les heures bridees ne disent rien de la ressource disponible.",
mappings=[{"type": "value", "options": {
"libre (mesure valide)": {"color": "green", "index": 0},
"bridee (mesure inexploitable)": {"color": "orange", "index": 1}}}]),
table("Les previsions telles qu'elles ont ete publiees", [
sql("A", "SELECT publie_a AS \"Publiee le\",\n"
" to_char(jour, 'DD/MM') AS \"Pour le jour\",\n"
" round(total_kwh::numeric, 1) AS \"Total annonce (kWh)\",\n"
" round((horaire[8] + horaire[9] + horaire[10] + horaire[11])::numeric, 2) AS \"07-11 h\",\n"
" round((horaire[12] + horaire[13] + horaire[14] + horaire[15])::numeric, 2) AS \"11-15 h\",\n"
" round((horaire[16] + horaire[17] + horaire[18] + horaire[19])::numeric, 2) AS \"15-19 h\"\n"
"FROM prevision\n"
"WHERE $__timeFilter(publie_a)\n"
"ORDER BY publie_a DESC, jour", fmt="table")],
PG, 0, 15, 24, 9,
"Sans l'horodatage de PUBLICATION, on ne peut pas mesurer le biais : "
"comparer une prevision au reel n'a de sens que si l'on sait quand elle "
"a ete faite. C'est la raison d'etre de cette table."),
])
# =====================================================================
# 4. TELEPHONE — une colonne, peu de panneaux, la reponse d'abord
# =====================================================================
# Contrainte de conception : sous ~770 px de large, Grafana passe CHAQUE
# panneau en pleine largeur et les empile. Le nombre de panneaux devient donc
# le nombre d'ecrans a faire defiler. D'ou le choix de regrouper les chiffres
# dans deux panneaux « stat » plutot que d'en faire six.
def seuils(*paires):
return {"mode": "absolute", "steps": [{"color": c, "value": v} for c, v in paires]}
def sur(nom, props):
return {"matcher": {"id": "byName", "options": nom}, "properties": props}
telephone = dashboard("evcs-telephone", "Sur le telephone", (
"Une colonne, pensee pour un ecran de telephone. Repond a une seule "
"question : est-ce que la nuit qui vient va bien se passer ?"),
frm="now-12h", panels=[
stat("Maintenant", [
sql("A",
"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\"", 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.",
overrides=[
sur("Batterie", [{"id": "unit", "value": "percent"},
{"id": "thresholds", "value": seuils(
("red", None), ("orange", 25), ("yellow", 40), ("green", 60))}]),
sur("Marge", [{"id": "unit", "value": "percent"},
{"id": "decimals", "value": 1},
{"id": "thresholds", "value": seuils(("red", None), ("green", 0))}]),
sur("Excedent", [{"id": "unit", "value": "watt"},
{"id": "decimals", "value": 0},
{"id": "thresholds", "value": seuils(
("blue", None), ("green", 0))}])]),
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"
"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.",
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))}])]),
ts("Ce qu'elle a, ce qu'il lui faut", [
sql("A", "SELECT ts AS time,\n"
" soc_pct AS \"Batterie\",\n"
" soc_requis_pct AS \"Requis\"\n"
"FROM bilan WHERE $__timeFilter(ts) ORDER BY 1")],
PG, 0, 10, 24, 7, "percent",
"Quand le vert passe sous l'orange, le reseau sera necessaire.",
legend="bottom",
extra={"defaults": {"min": 0, "max": 100},
"overrides": [
sur("Batterie", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}},
{"id": "custom.lineWidth", "value": 3}]),
sur("Requis", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "orange"}},
{"id": "custom.fillOpacity", "value": 0}])]}),
ts("Ce qui reste apres la maison", [
sql_grandeurs("A", [
('sensor.victron_pv_power_system', 'Production'),
('sensor.victron_consumption_power_l1', 'Maison')],
calculees=[('Production - Maison', 'Excedent')])],
PG, 0, 17, 24, 7, "watt",
"Au-dessus de zero, la voiture peut charger sans rien couter. En dessous, "
"la charger revient a vider la batterie.",
extra={"overrides": [
sur("Production", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "yellow"}}]),
sur("Maison", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "text"}},
{"id": "custom.fillOpacity", "value": 0}]),
sur("Excedent", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}},
{"id": "custom.lineWidth", "value": 3}])]}),
timeline("Le reseau", [
sql("A", "SELECT ts AS time,\n"
" source_mode AS \"Source\",\n"
" CASE WHEN session_engagee THEN 'engagee' ELSE '-' END AS \"Session\"\n"
"FROM decision WHERE $__timeFilter(ts) ORDER BY 1", fmt="table")],
PG, 0, 24, 24, 5,
"Une seule bascule par nuit : c'est le but de la session unique.",
mappings=[{"type": "value", "options": {
"-": {"color": "dark-blue", "index": 0},
"engagee": {"color": "orange", "index": 1}}}]),
table("Ce qui s'est passe", [
sql("A", "SELECT to_char(ts, 'DD/MM HH24:MI') AS \"Quand\",\n"
" resume AS \"Quoi\"\n"
"FROM evenement ORDER BY ts DESC LIMIT 8", fmt="table")],
PG, 0, 29, 24, 7,
"Les huit derniers evenements, quelle que soit la fenetre de temps.",
overrides=[sur("Quand", [{"id": "custom.width", "value": 110}])]),
])
# =====================================================================
# 5. LE SYSTEME — replique locale de la vue generale VRM
# =====================================================================
# Raison d'etre : la console VRM est hebergee dans le cloud Victron. Elle
# disparait exactement quand on en a le plus besoin — une coupure d'orage sur
# ce site emporte souvent Internet avec elle. Tout ce qui suit est lu sur le
# reseau local, sans exception.
#
# Choix de methode : les energies horaires sont obtenues en INTEGRANT LA
# PUISSANCE, et non en lisant les compteurs kWh de Victron. Verifie le
# 04/09 : `gx_device_pv_energy` et les compteurs d'energie batterie du GX se
# remettent a zero sans le signaler, et Home Assistant enregistre alors une
# variation de -373 kWh sur une heure. Integrer la puissance ignore
# completement ce probleme.
def flux_dernier(refid, mesure, entite, libelle, champ="value", fenetre="24h"):
"""Derniere valeur connue d'une entite, renommee pour l'affichage."""
# LA FENETRE DEPEND DU COMPORTEMENT DE L'ENTITE AU REPOS, pas d'un
# compromis unique. C'est la lecon de deux erreurs successives :
#
# - trop LARGE partout (7 jours) : le panneau Reseau affichait le 05/09 a
# minuit « Courant 2,2 A », valeur ecrite quatorze heures plus tot. Les
# capteurs du reseau passent en `unavailable` quand AC-IN disparait ;
# ils ne tombent pas a zero, et leur derniere valeur numerique ment.
#
# - trop ETROITE partout (6 heures) : a 03:20 le panneau PV affichait
# « No data » alors que la production est nulle et connue — sa derniere
# ecriture datait du coucher du soleil, 370 minutes plus tot.
#
# Regle : une entite qui publie un VRAI zero au repos (production PV, borne
# a l'arret, trackers) supporte une fenetre large, sa derniere valeur restant
# exacte. Une entite qui DISPARAIT au repos exige une fenetre courte — c'est
# le cas des seules grandeurs du reseau, qui passent donc 6h en argument.
return flux(refid,
f'from(bucket: "evcs")\n'
f' |> range(start: -{fenetre})\n'
f' |> filter(fn: (r) => r._measurement == "{mesure}"'
f' and r.entity_id == "{entite}" and r._field == "{champ}")\n'
f' |> last()\n'
f' |> keep(columns: ["_value"])\n'
f' |> rename(columns: {{_value: "{libelle}"}})')
# Energie horaire par integration ponderee par la duree, plafonnee a 300 s
# pour qu'un arret du service n'extrapole pas une valeur perimee.
ENERGIE_CLES = [
('sensor.victron_pv_power_system', 'Solaire'),
('sensor.victron_grid_power_l1', 'Reseau'),
('sensor.victron_consumption_power_l1', 'Maison'),
('sensor.victron_evcs_ac_power', 'Voiture'),
]
# Energie horaire = moyenne de la puissance sur l'heure, en kWh.
#
# Deux pieges, tous deux rencontres le 04/09 avant d'arriver a cette version :
#
# 1. Somme des echantillons ponderee par leur intervalle : laisse des TROUS.
# « Solaire » n'existait qu'a 00 h, 07 h, 08 h et 09 h, parce qu'une
# grandeur qui ne change pas n'est jamais reecrite — la production nulle
# de la nuit ne produit qu'UNE ligne, et les heures sans ligne sortaient
# vides au lieu de sortir zero.
#
# 2. Moyenne des minutes PRESENTES : sur-compte. L'heure 01 annoncait
# 5,13 kWh de reseau alors que la fenetre heures creuses ne s'ouvre qu'a
# 01:24 — la moyenne portait sur 36 minutes et les comptait comme 60.
#
# 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
),
-- 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
-- l'heure au jour puis au mois.
pas AS (
SELECT CASE WHEN f - d > interval '70 days' THEN 'month'
WHEN f - d > interval '4 days' THEN 'day'
ELSE 'hour' END AS unite,
CASE WHEN f - d > interval '70 days' THEN 'MM/YYYY'
WHEN f - d > interval '4 days' THEN 'DD/MM'
ELSE 'DD/MM HH24' END AS forme
FROM bornes
),
axe AS (
SELECT generate_series(date_trunc('minute', (SELECT d FROM bornes)),
date_trunc('minute', (SELECT f FROM bornes)),
interval '1 minute') AS t
),
releve AS (
SELECT date_trunc('minute', ts) AS t,
""" + ",\n".join(
f" avg(valeur) FILTER (WHERE cle = '{c}') AS {n},\n"
f" (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = '{c}'))[1] AS d_{n}"
for c, n in ENERGIE_CLES) + """
FROM mesure WHERE $__timeFilter(ts) GROUP BY 1
),
par_minute AS (
SELECT axe.t, """ + ", ".join(
f"releve.{n}, releve.d_{n}" for _, n in ENERGIE_CLES) + """
FROM axe LEFT JOIN releve USING (t)
),
paliers AS (
SELECT t, """ + ", ".join(
f"{n}, d_{n}" for _, n in ENERGIE_CLES) + ",\n" + ",\n".join(
f" count(d_{n}) OVER (ORDER BY t) AS g_{n}" for _, n in ENERGIE_CLES) + """
FROM par_minute
),
report AS (
SELECT t,
""" + ",\n".join(
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",
""" + ",\n".join(
# Energie REELLEMENT accumulee : puissance moyenne x duree observee.
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)"""
# 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.
TRACKERS_FLUX = '''import "strings"
base = from(bucket: "evcs")
|> range(start: -30m)
|> filter(fn: (r) => r._field == "value")
|> filter(fn: (r) => r._measurement == "V" or r._measurement == "A" or r._measurement == "W")
|> last()
rs = base
|> filter(fn: (r) => strings.containsStr(v: r.entity_id, substr: "pv_tracker_solar_"))
|> map(fn: (r) => ({ r with tracker:
if strings.containsStr(v: r.entity_id, substr: "solar_1") then "Sud 1 (10 pan.)"
else if strings.containsStr(v: r.entity_id, substr: "solar_2") then "Sud 2 (10 pan.)"
else if strings.containsStr(v: r.entity_id, substr: "solar_3") then "Sud 3 (10 pan.)"
else "Ouest (9 pan.)" }))
se = base
|> filter(fn: (r) => r.entity_id == "airtria_mppt1_pv_bus_voltage"
or r.entity_id == "airtria_mppt1_pv_current"
or r.entity_id == "airtria_mppt1_pv_yield_power")
|> map(fn: (r) => ({ r with tracker: "Sud-est (10 pan.)" }))
union(tables: [rs, se])
|> keep(columns: ["tracker", "_measurement", "_value"])
|> pivot(rowKey: ["tracker"], columnKey: ["_measurement"], valueColumn: "_value")
|> sort(columns: ["tracker"])'''
def col(nom, unite, largeur=None, dec=0):
props = [{"id": "unit", "value": unite}, {"id": "decimals", "value": dec}]
if largeur:
props.append({"id": "custom.width", "value": largeur})
return sur(nom, props)
# Selecteur de vue, dans l'esprit de VRM : la meme representation, restreinte
# aux grandeurs qui interessent. La valeur de chaque option est directement une
# alternative d'expression reguliere, consommee par la transformation
# « filterFieldsByName » des panneaux — d'ou le separateur « | ».
_VUES = [
("Vue generale", "Solaire|Reseau|Batterie|Maison|Voiture"),
("Consommation", "Maison|Voiture"),
("Reseau", "Reseau|Maison"),
("Batterie", "Batterie|Solaire|Maison"),
("Chargeur VE", "Voiture|Solaire"),
]
VARIABLE_VUE = {
"name": "vue", "label": "Vue", "type": "custom", "hide": 0, "skipUrlSync": False,
"query": ",".join(f"{t} : {v}" for t, v in _VUES),
"options": [{"selected": i == 0, "text": t, "value": v} for i, (t, v) in enumerate(_VUES)],
"current": {"selected": True, "text": _VUES[0][0], "value": _VUES[0][1]},
"multi": False, "includeAll": False,
}
# « Time » avec une MAJUSCULE : c'est ainsi que la source PostgreSQL nomme le
# champ temporel dans le format time_series. Ecrit en minuscules, le filtre
# supprimait l'axe des temps et le panneau repondait « Data is missing a time
# field ». Les deux graphies sont acceptees, la casse etant significative ici.
FILTRE_VUE = [{"id": "filterFieldsByName",
"options": {"include": {"pattern": "^(Time|time|Heure|$vue)$"}}}]
systeme = dashboard("evcs-systeme", "Le systeme", (
"Replique locale de la vue generale VRM. La console Victron est hebergee "
"dans le cloud : elle disparait quand une coupure d'orage emporte "
"Internet. Tout ce qui est affiche ici est lu sur le reseau local."),
frm="now-24h", refresh="30s", variables=[VARIABLE_VUE], panels=[
stat("Reseau", [
# 6 h et non 24 : ces trois capteurs DISPARAISSENT quand AC-IN s'ouvre,
# laissant derriere eux une valeur qui ne vaut plus rien.
flux_dernier("A", "W", "victron_grid_power_l1", "Puissance", fenetre="6h"),
flux_dernier("B", "V", "airtria_mpii_v1_input_voltage_l1", "Tension", fenetre="6h"),
flux_dernier("C", "A", "gx_device_grid_current_l1", "Courant", fenetre="6h")],
IN, 0, 0, 5, 7,
"Positif : le reseau alimente la maison. Le contacteur EDF est pilote par "
"le workflow d'hysteresis, pas depuis ici.",
overrides=[col("Puissance", "watt"), col("Tension", "volt"), col("Courant", "amp", dec=1)]),
stat("Charges CA", [
flux_dernier("A", "W", "victron_consumption_power_l1", "Consommation"),
flux_dernier("B", "Hz", "airtria_mpii_v1_output_frequency_l1", "Frequence")],
IN, 5, 0, 4, 7,
"Ce que la maison consomme, voiture comprise.",
overrides=[col("Consommation", "watt"), col("Frequence", "hertz", dec=1)]),
stat("Batterie", [
flux_dernier("A", "%", "victron_battery_soc", "Charge"),
flux_dernier("B", "W", "victron_battery_power", "Puissance"),
flux_dernier("C", "V", "victron_battery_voltage", "Tension"),
flux_dernier("D", "A", "victron_battery_current", "Courant"),
flux_dernier("E", "°C", "victron_battery_temperature", "Temperature")],
IN, 9, 0, 9, 7,
"Puissance positive : la batterie se charge. Sept modules actifs sur huit, "
"un pack bloquant la charge vers 94 %.",
overrides=[
sur("Charge", [{"id": "unit", "value": "percent"}, {"id": "decimals", "value": 0},
{"id": "thresholds", "value": seuils(
("red", None), ("orange", 25), ("yellow", 40), ("green", 60))}]),
col("Puissance", "watt"), col("Tension", "volt", dec=2),
col("Courant", "amp", dec=1), col("Temperature", "celsius")],
# Cinq valeurs dans huit colonnes : Grafana reduisait le texte jusqu'a
# l'illisible. Dix colonnes et une taille imposee, plutot que de laisser
# l'ajustement automatique decider.
taille={"valueSize": 22, "titleSize": 11}),
dict(stat("Chargeur VE", [
flux_dernier("A", "W", "victron_evcs_ac_power", "Puissance"),
flux_dernier("B", "A", "victron_evcs_current", "Courant"),
flux_dernier("C", "A", "victron_evcs_set_current", "Consigne"),
sql("D", "-- Le compteur de la BORNE, repris de la table des sessions : il\n"
"-- compte depuis le vrai debut, la ou une integration ne couvre que\n"
"-- ce que le service a vu. Session close depuis plus de 2 h : rien.\n"
"SELECT round(coalesce(energie_station_kwh, energie_kwh)::numeric, 2) AS \"Session\"\n"
"FROM session_charge\n"
"WHERE fin > now() - interval '2 hours'\n"
"ORDER BY debut DESC LIMIT 1", fmt="table")],
IN, 18, 0, 6, 7,
"Consigne = ce que le controleur demande a la borne, reecrit toutes les "
"30 s. Courant = ce que la voiture prend reellement.",
overrides=[col("Puissance", "watt"), col("Courant", "amp", dec=1),
col("Consigne", "amp", dec=0),
sur("Session", [{"id": "unit", "value": "kwatth"},
{"id": "decimals", "value": 2},
{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}])],
taille={"valueSize": 22, "titleSize": 11}),
# Sources melangees : le panneau porte « Mixed », chaque requete la sienne.
datasource={"type": "datasource", "uid": "-- Mixed --"}),
stat("Chargeur PV", [
flux_dernier("A", "W", "victron_pv_power_system", "Production"),
flux_dernier("B", "W", "victron_pv_power_10m_avg", "Moyenne 10 min")],
IN, 0, 7, 5, 8,
"Rappel qui vaut pour toute lecture de cette valeur : sans injection, la "
"production MESUREE vaut min(ressource, consommation + absorption "
"batterie). Basse ne veut pas dire mauvais temps.",
overrides=[col("Production", "watt"), col("Moyenne 10 min", "watt")]),
dict(table("Detail par tracker", [flux("A", TRACKERS_FLUX)],
IN, 5, 7, 13, 8,
"Les 52 panneaux en quatre plans : sud-est 40 degres, trois chaines sud "
"35 degres, ouest 45 degres. Le suiveur de trois panneaux ne publie pas "
"de mode de fonctionnement et n'apparait donc pas ici.",
overrides=[sur("tracker", [{"id": "displayName", "value": "Tracker"},
{"id": "custom.width", "value": 200}]),
sur("V", [{"id": "displayName", "value": "Tension"},
{"id": "unit", "value": "volt"}, {"id": "decimals", "value": 0}]),
sur("A", [{"id": "displayName", "value": "Courant"},
{"id": "unit", "value": "amp"}, {"id": "decimals", "value": 1}]),
sur("W", [{"id": "displayName", "value": "Puissance"},
{"id": "unit", "value": "watt"}, {"id": "decimals", "value": 0},
{"id": "custom.cellOptions",
"value": {"type": "gauge", "mode": "gradient"}},
{"id": "max", "value": 3100}])]),
# `pivot` rend les colonnes par ordre alphabetique (A, V, W). On retablit
# l'ordre de lecture : tension, courant, puissance.
transformations=[{"id": "organize", "options": {
"indexByName": {"tracker": 0, "V": 1, "A": 2, "W": 3}}}]),
stat("Ce que le BMS impose", [
# Fenetre de 48 h pour ce panneau, et c'est deliberement different du
# reste. Un etat d'alarme ne devient jamais « indisponible » : il se
# publie. Sa derniere valeur EST donc la valeur courante, meme vieille de
# dix heures — a l'inverse du courant reseau, dont le capteur disparait et
# laisse derriere lui un chiffre qui ment.
flux_dernier("A", "A", "airtria_8_us2000c_maximum_allowed_charge_current", "Charge max", fenetre="48h"),
flux("B", 'from(bucket: "evcs")\n'
' |> range(start: -48h)\n'
' |> filter(fn: (r) => r._measurement == "sensor.airtria_8_us2000c_tension_de_cellule_elevee"'
' and r._field == "state")\n'
' |> last()\n'
' |> keep(columns: ["_value"])\n'
' |> rename(columns: {_value: "Tension cellule"})'),
flux("C", 'from(bucket: "evcs")\n'
' |> range(start: -48h)\n'
' |> filter(fn: (r) => r._measurement == "sensor.airtria_8_us2000c_cell_imbalance"'
' and r._field == "state")\n'
' |> last()\n'
' |> keep(columns: ["_value"])\n'
' |> rename(columns: {_value: "Desequilibre"})'),
flux_dernier("D", "%", "airtria_8_us2000c_state_of_health", "Sante", fenetre="48h")],
IN, 18, 7, 6, 8,
"« Charge max » est la limite que le BMS impose au systeme entier. Le "
"04/09 elle est tombee de 150 a 20 A en trois minutes, des 76 % de charge, "
"au moment ou l'alarme de tension de cellule s'est declenchee — le parc "
"n'etait alors qu'a 50,76 V pour 53,2 V autorises. Ce n'est pas le parc "
"qui sature, c'est une cellule.",
overrides=[col("Charge max", "amp"), col("Sante", "percent")],
taille={"valueSize": 24, "titleSize": 11},
champs="/.*/",
mappings=[{"type": "value", "options": {
"no_alarm": {"text": "OK", "color": "green", "index": 0},
"warning": {"text": "AVERTISSEMENT", "color": "orange", "index": 1},
"alarm": {"text": "ALARME", "color": "red", "index": 2}}}]),
ts("Les flux", [
# fmt="table" et non "time_series" : le filtre de vue s'applique bien sur
# une trame de type table — c'est ce que fait le graphique en barres — et
# echouait sur time_series avec « Data is missing a time field ». Le
# panneau time series accepte une trame table portant une colonne
# temporelle, le rendu est identique.
sql_grandeurs("A", [
('sensor.victron_pv_power_system', 'Solaire'),
('sensor.victron_grid_power_l1', 'Reseau'),
('sensor.victron_battery_power', 'Batterie'),
('sensor.victron_consumption_power_l1', 'Maison'),
('sensor.victron_evcs_ac_power', 'Voiture')], fmt="table")],
PG, 0, 15, 24, 9, "watt",
"Batterie positive : elle se charge. Reseau positif : il alimente. "
"Aux instants ou tout est a zero sauf la maison, c'est la batterie qui "
"porte tout.",
extra={"overrides": [
sur("Solaire", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "yellow"}}]),
sur("Reseau", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}]),
sur("Batterie", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "blue"}}]),
sur("Maison", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "text"}},
{"id": "custom.fillOpacity", "value": 0}]),
sur("Voiture", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}])],
"transformations": FILTRE_VUE}),
dict(barchart("Energie par periode", [sql("A", ENERGIE_SQL, fmt="table")],
PG, 0, 24, 24, 9, "kwatth",
"Le pas suit la periode choisie : l'heure jusqu'a 4 jours, le jour "
"jusqu'a 70, le mois au-dela. Obtenue en integrant la puissance, PAS en "
"lisant les compteurs kWh du GX : ceux-ci se remettent a zero sans le "
"signaler (verifie le 04/09, -373 kWh enregistres sur une heure cote "
"Home Assistant)."),
transformations=FILTRE_VUE),
])
# =====================================================================
# 6. LES VEHICULES — ce que chaque voiture encaisse, et sur quelle periode
# =====================================================================
# ⚠️ LIMITE STRUCTURELLE, a garder en tete a chaque lecture de ce tableau.
# L'etiquette du vehicule vient d'un SELECTEUR MANUEL. Elle est fiable quand
# le proprietaire l'a positionnee au branchement, et fausse s'il l'a oubliee ou
# si un redemarrage de Home Assistant l'a annulee (constate le 04/09 a 19:44).
#
# Il n'existe aucun moyen de la verifier :
# - electriquement, les deux voitures sont indiscernables. Mesure du 05/09 a
# consigne identique de 20 A : la VW accepte 3 680 W, la JEEP 3 655 W,
# soit 0,1 A d'ecart. Meme chargeur embarque de 3,7 kW, meme plateau.
# - horairement, la routine s'inverse le week-end : en semaine la JEEP charge
# le midi et la VW la nuit, mais le samedi la JEEP charge la nuit et la VW
# en journee. Une regle horaire serait fausse un week-end sur deux.
#
# D'ou la colonne « Doute » du tableau et le compteur d'etiquettes douteuses :
# on affiche l'incertitude au lieu de la masquer.
SESSIONS_PERIODE = """WITH bornes AS (
SELECT $__timeFrom()::timestamptz AS d, $__timeTo()::timestamptz AS f
),
pas AS (
SELECT CASE WHEN f - d > interval '70 days' THEN 'month'
WHEN f - d > interval '4 days' THEN 'day'
ELSE 'hour' END AS unite,
CASE WHEN f - d > interval '70 days' THEN 'MM/YYYY'
WHEN f - d > interval '4 days' THEN 'DD/MM'
ELSE 'DD/MM HH24' END AS forme
FROM bornes
)
SELECT to_char(date_trunc(pas.unite, debut), pas.forme) AS "Periode",
round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule = 'VW'), 0)::numeric, 2) AS "VW",
round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule = 'JEEP'), 0)::numeric, 2) AS "JEEP",
round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule IS NULL), 0)::numeric, 2) AS "Non etiquete"
FROM session_charge, pas
WHERE $__timeFilter(debut)
GROUP BY date_trunc(pas.unite, debut), pas.forme
ORDER BY date_trunc(pas.unite, debut)"""
vehicules = dashboard("evcs-vehicules", "Les vehicules", (
"Ce que chaque voiture encaisse, session par session et sur la periode "
"choisie. L'etiquette vient d'un selecteur manuel : elle peut etre fausse, "
"et rien ne permet de la verifier — les deux vehicules sont "
"electriquement indiscernables et leur routine s'inverse le week-end."),
frm="now-7d", refresh="5m", panels=[
stat("Sur la periode", [
sql("A",
"SELECT round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule = 'VW'), 0)::numeric, 1) AS \"VW\",\n"
" round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule = 'JEEP'), 0)::numeric, 1) AS \"JEEP\",\n"
" count(*) AS \"Sessions\",\n"
" count(*) FILTER (WHERE NOT vehicule_sur OR vehicule IS NULL) AS \"Etiquette douteuse\"\n"
"FROM session_charge WHERE $__timeFilter(debut)", fmt="table")],
PG, 0, 0, 24, 5,
"« Etiquette douteuse » compte les sessions ou le selecteur a change en "
"cours de route, ou n'etait pas renseigne. C'est la mesure honnete de "
"l'incertitude : elle ne peut pas etre levee autrement qu'en positionnant "
"le selecteur au branchement.",
taille={"valueSize": 34, "titleSize": 12},
overrides=[
sur("VW", [{"id": "unit", "value": "kwatth"},
{"id": "color", "value": {"mode": "fixed", "fixedColor": "blue"}}]),
sur("JEEP", [{"id": "unit", "value": "kwatth"},
{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}]),
sur("Etiquette douteuse", [{"id": "thresholds", "value": seuils(
("green", None), ("orange", 1))}])]),
barchart("Energie par periode et par vehicule",
[sql("A", SESSIONS_PERIODE, fmt="table")],
PG, 0, 5, 24, 9, "kwatth",
"Le pas suit la plage choisie : l'heure jusqu'a 4 jours, le jour jusqu'a "
"70, le mois au-dela. « Non etiquete » est ce qui a ete charge sans que le "
"selecteur soit renseigne — a interpreter, pas a ignorer."),
table("Les sessions", [
sql("A",
"SELECT to_char(debut, 'Dy DD/MM HH24:MI') AS \"Debut\",\n"
" coalesce(vehicule, '?') AS \"Vehicule\",\n"
" CASE WHEN vehicule_sur THEN '' ELSE 'selecteur change' END AS \"Doute\",\n"
" round((extract(epoch FROM fin - debut) / 60)::numeric) AS \"Minutes\",\n"
" round(coalesce(energie_station_kwh, energie_kwh)::numeric, 2) AS \"kWh\",\n"
" round(energie_kwh::numeric, 2) AS \"kWh integres\",\n"
" round(energie_reseau_kwh::numeric, 2) AS \"dont reseau\",\n"
" round(p_max_w::numeric) AS \"P max\",\n"
" consigne_max_a AS \"Consigne max\",\n"
" source_mode AS \"Source au depart\",\n"
" to_char(debranche_a, 'Dy HH24:MI') AS \"Debranche\"\n"
"FROM session_charge WHERE $__timeFilter(debut) ORDER BY debut DESC", fmt="table")],
PG, 0, 14, 24, 11,
"« kWh » vient du compteur de la BORNE, qui compte depuis le vrai debut "
"de la session. « kWh integres » est mon propre calcul, qui ne couvre que "
"ce que le service a vu — un ecart entre les deux signale un demarrage ou "
"un redemarrage en cours de session, pas une erreur de mesure. "
"« Debranche » confirme le vehicule apres coup : les deux partent a des "
"heures differentes. « dont reseau » se fonde sur le mode source declare "
"par le controleur — son intention — et non sur la puissance reseau "
"instantanee, qui peut alimenter la maison pendant que le soleil alimente "
"la voiture.",
overrides=[
sur("Vehicule", [{"id": "custom.width", "value": 100},
{"id": "custom.cellOptions", "value": {"type": "color-text"}},
{"id": "mappings", "value": [{"type": "value", "options": {
"VW": {"color": "blue", "index": 0},
"JEEP": {"color": "green", "index": 1},
"?": {"color": "orange", "index": 2}}}]}]),
sur("Doute", [{"id": "custom.width", "value": 150}]),
sur("kWh", [{"id": "unit", "value": "kwatth"}]),
sur("kWh integres", [{"id": "unit", "value": "kwatth"},
{"id": "custom.width", "value": 110}]),
sur("dont reseau", [{"id": "unit", "value": "kwatth"}]),
sur("P max", [{"id": "unit", "value": "watt"}])]),
])
# =====================================================================
# 7. LES BATTERIES — ce qu'aucun capteur Victron ne publie
# =====================================================================
# Le GX n'expose que l'agregat du parc : une tension, un courant, un SOC. Les
# quinze tensions de cellule d'un module ne sortent QUE par la console serie de
# son BMS. C'est pour cela que le desequilibre est reste invisible des annees.
VARIABLE_MODULE = {
"name": "module", "label": "Module", "type": "query", "datasource": PG,
"refresh": 1, "includeAll": False, "multi": False,
"query": "SELECT DISTINCT module FROM cellule ORDER BY 1",
}
# Echelle d'alarme du materiel, lue dans `sysinfo` du LVHUB le 05/09/2026.
# Ce ne sont pas des valeurs choisies ici.
SEUILS_CELLULE = seuils(("green", None), ("#EAB839", 3480), ("orange", 3520),
("red", 3550), ("dark-red", 3600))
batteries = dashboard("evcs-batteries", "Les batteries", (
"Les quinze tensions de cellule de chaque module Pylontech, relevees sur la "
"console de leur BMS. Aucune de ces valeurs n'existe cote Victron, qui ne "
"publie que l'agregat du parc. Les paliers de couleur sont ceux du materiel "
"lui-meme : 3480, 3520, 3550 et 3600 mV."), [
ts("Cellule par cellule — $module", [
sql("A", "SELECT ts AS time, 'cellule ' || lpad(cellule::text, 2, '0') AS metric,\n"
" mv\n"
"FROM cellule\n"
"WHERE module = '$module' AND $__timeFilter(ts)\n"
"ORDER BY 1, 2")],
PG, 0, 0, 24, 10, unit="mvolt",
desc="Une courbe par cellule. Une cellule saine suit exactement ses "
"quatorze voisines ; une cellule qui a perdu de la capacite s'en "
"detache en haut de charge ET en bas de decharge, parce qu'elle se "
"remplit et se vide avant les autres.",
extra={"fieldConfig": {"defaults": {"custom": {"lineWidth": 1},
"thresholds": SEUILS_CELLULE,
"custom.thresholdsStyle": {"mode": "dashed"}}}},
legend="right"),
# ⚠️ Ces deux panneaux COMPARENT les modules entre eux : ils ne doivent
# montrer que les modules ENCORE SUR LA CHAINE. Le releveur lit aussi les
# modules isoles tant qu'ils sont sous tension — le pack ancien a cesse
# d'emettre le 05/09 mais restait affiche sur une fenetre de sept jours,
# soit huit courbes dont quatre mortes. Le critere est « a emis dans les
# deux dernieres heures », pas un prefixe de code-barres : rebrancher un
# pack le fait revenir tout seul.
ts("Ecart entre la plus haute et la plus basse", [
sql("A", "SELECT ts AS time, module AS metric, max(mv) - min(mv) AS ecart\n"
"FROM cellule WHERE $__timeFilter(ts)\n"
" AND module IN (SELECT module FROM cellule\n"
" GROUP BY module HAVING max(ts) > now() - interval '2 hours')\n"
"GROUP BY ts, module ORDER BY 1")],
PG, 0, 10, 12, 8, unit="mvolt",
desc="L'indicateur qui resume un module en une courbe. Un module sain de "
"ce parc tient sous 20 mV ; au-dela de 150 mV le BMS commence a "
"reduire le courant de charge de TOUT le parc."),
ts("La cellule meneuse, contre le plafond que le BMS impose", [
sql("A", "SELECT ts AS time, module || ' — cellule meneuse' AS metric,\n"
" max(mv) AS mv\n"
"FROM cellule WHERE $__timeFilter(ts)\n"
" AND module IN (SELECT module FROM cellule\n"
" GROUP BY module HAVING max(ts) > now() - interval '2 hours')\n"
"GROUP BY ts, module ORDER BY 1"),
sql("B", "SELECT ts AS time, 'plafond de charge (A)' AS metric,\n"
" valeur * 20 AS mv\n"
"FROM mesure\n"
"WHERE cle = 'sensor.airtria_8_us2000c_maximum_allowed_charge_current'\n"
" AND $__timeFilter(ts) ORDER BY 1")],
PG, 12, 10, 12, 8, unit="mvolt",
desc="Le plafond de charge est trace a l'echelle (20 mV par ampere) pour "
"tenir sur le meme axe. Le 05/09 a 13:05:08 la cellule meneuse est "
"passee de 3541 a 3555 mV ; cinq secondes plus tard le plafond "
"tombait de 50 a 30 A. Ensuite le courant baisse, la cellule se "
"detend, l'alarme repasse au vert — et le plafond, lui, reste bas.",
extra={"fieldConfig": {"defaults": {"thresholds": SEUILS_CELLULE}}}),
table("Amplitude de chaque cellule sur la periode", [
sql("A", "SELECT cellule AS \"Cellule\",\n"
" min(mv) AS \"Minimum\", max(mv) AS \"Maximum\",\n"
" max(mv) - min(mv) AS \"Amplitude\"\n"
"FROM cellule\n"
"WHERE module = '$module' AND $__timeFilter(ts)\n"
"GROUP BY cellule ORDER BY 4 DESC", fmt="table")],
PG, 0, 18, 12, 10,
desc="Le classement qui designe les cellules a traiter. Sur un module "
"sain les quinze amplitudes sont identiques a quelques millivolts "
"pres. Une amplitude de trois a quatre fois celle des voisines "
"designe une cellule qui a perdu sa capacite — et cela ne se "
"rattrape pas par un reequilibrage.",
overrides=[sur("Amplitude", [{"id": "custom.cellOptions",
"value": {"type": "color-background"}},
{"id": "unit", "value": "mvolt"},
{"id": "thresholds", "value": seuils(
("green", None), ("#EAB839", 250),
("orange", 400), ("red", 600))}]),
sur("Minimum", [{"id": "unit", "value": "mvolt"}]),
sur("Maximum", [{"id": "unit", "value": "mvolt"}])]),
ts("Etat de charge du parc pendant les relevés", [
sql("A", "SELECT ts AS time, valeur AS \"SOC du parc\"\n"
"FROM mesure WHERE cle = 'sensor.victron_battery_soc'\n"
" AND $__timeFilter(ts) ORDER BY 1"),
sql("B", "SELECT ts AS time, valeur AS \"Courant du parc\"\n"
"FROM mesure WHERE cle = 'sensor.victron_battery_current'\n"
" AND $__timeFilter(ts) ORDER BY 1")],
PG, 12, 18, 12, 10,
desc="Le contexte sans lequel un ecart entre cellules ne veut rien dire : "
"l'ecart grandit avec l'etat de charge, et l'excursion de la cellule "
"abimee grandit avec le courant. Le 05/09, a tension de parc egale, "
"la meneuse etait a 3555 mV sous 50 A et a 3496 mV sous 30 A."),
], frm="now-7d", variables=[VARIABLE_MODULE])
# =====================================================================
# 8. LA JOURNEE — le bilan condense, de minuit a maintenant
# =====================================================================
# Les sept autres tableaux sont thematiques : on y va quand on sait deja quoi
# chercher. Celui-ci repond a la question qu'on se pose en premier — « qu'est-ce
# qui s'est passe aujourd'hui ». D'ou la fenetre `now/d` (le jour civil en
# cours) et non `now-24h`, qui melangerait deux journees et rendrait le total
# de production incomparable d'un jour a l'autre.
#
# Toutes les energies sont obtenues en INTEGRANT la puissance, jamais en lisant
# les compteurs kWh du GX : ceux-ci se remettent a zero sans le signaler
# (verifie le 04/09, -373 kWh enregistres sur une heure cote Home Assistant).
BILAN_CLES = [
('sensor.victron_pv_power_system', 'pv'),
('sensor.victron_consumption_power_l1', 'maison'),
('sensor.victron_grid_power_l1', 'reseau'),
('sensor.victron_evcs_ac_power', 'voiture'),
('sensor.victron_battery_power', 'batterie'),
]
_bn = [n for _, n in BILAN_CLES]
# ⚠️ Les bornes de l'axe viennent des DONNEES (min/max de `mesure` dans la
# fenetre), pas des macros `$__timeFrom()` / `$__timeTo()`. Ce panneau rendait
# sept « — » a l'ecran alors que la requete donnait les bons totaux en psql et
# que Grafana ne journalisait aucune erreur : une ligne de NULL, donc un axe
# vide cote serveur. La seule macro utilisee ici est `$__timeFilter(ts)`, comme
# dans le panneau « Sur la periode » des vehicules, qui lui a toujours marche.
# C'est aussi plus juste physiquement : on n'extrapole pas au-dela de la
# derniere mesure observee.
BILAN_BASE = ("""WITH bornes AS (
SELECT min(ts) AS d, max(ts) AS f FROM mesure WHERE $__timeFilter(ts)
),
axe AS (
SELECT generate_series(date_trunc('minute', (SELECT d FROM bornes)),
date_trunc('minute', (SELECT f FROM bornes)),
interval '1 minute') AS t
),
releve AS (
SELECT date_trunc('minute', ts) AS t,
"""
+ ",\n".join(
f" avg(valeur) FILTER (WHERE cle = '{c}') AS {n},\n"
f" (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = '{c}'))[1] AS d_{n}"
for c, n in BILAN_CLES) + """
FROM mesure WHERE $__timeFilter(ts) GROUP BY 1
),
par_minute AS (
SELECT axe.t, """ + ", ".join(f"releve.{n}, releve.d_{n}" for n in _bn) + """
FROM axe LEFT JOIN releve USING (t)
),
paliers AS (
SELECT t, """ + ", ".join(f"{n}, d_{n}" for n in _bn) + ",\n"
+ ",\n".join(f" count(d_{n}) OVER (ORDER BY t) AS g_{n}" for n in _bn) + """
FROM par_minute
),
report AS (
SELECT t,
""" + ",\n".join(
f" coalesce({n}, first_value(d_{n}) OVER (PARTITION BY g_{n} ORDER BY t), 0)"
f" AS {n}" for n in _bn) + """
FROM paliers
)
""")
# Une seule ligne, six colonnes : c'est le resume que l'on lit en premier.
# L'autonomie se calcule sur la maison SANS la voiture, sinon une grosse charge
# solaire du vehicule gonfle artificiellement le taux.
BILAN_TOTAUX = BILAN_BASE + """SELECT
round((sum(pv) / 60.0 / 1000.0)::numeric, 1) AS "Solaire produit",
round((sum(maison) / 60.0 / 1000.0)::numeric, 1) AS "Maison consommee",
round((sum(reseau) / 60.0 / 1000.0)::numeric, 1) AS "Reseau importe",
round((sum(voiture) / 60.0 / 1000.0)::numeric, 1) AS "Voiture",
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"
FROM report"""
BILAN_FLUX = BILAN_BASE + """SELECT t AS time,
pv AS "Production solaire",
maison AS "Consommation maison",
voiture AS "Charge du vehicule",
reseau AS "Reseau",
batterie AS "Batterie (+ charge, - decharge)"
FROM report ORDER BY t"""
journee = dashboard(
'evcs-journee', 'La journee',
"Le bilan de la journee en cours, de minuit a maintenant : ce qui a ete "
"produit, ce qui a ete consomme, ce qui a ete achete, et ou c'est alle. "
"Les energies sont integrees depuis les puissances mesurees.",
[
stat("Le bilan depuis minuit", [sql("A", BILAN_TOTAUX, fmt="table")],
PG, 0, 0, 24, 5,
desc="Sept chiffres, une ligne. 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.",
overrides=[sur(n, [{"id": "unit", "value": "kwatth"}]) for n in
("Solaire produit", "Maison consommee", "Voiture",
"Batterie chargee", "Batterie rendue")]
+ [sur("Reseau importe", [{"id": "unit", "value": "kwatth"},
{"id": "thresholds", "value": seuils(
("green", None), ("#EAB839", 3),
("orange", 8))}]),
sur("Autonomie", [{"id": "unit", "value": "percent"},
{"id": "thresholds", "value": seuils(
("red", None), ("#EAB839", 60),
("green", 85))}])]),
# ⚠️ PAS de FILTRE_VUE ici : cette transformation ne garde que les colonnes
# 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")],
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."),
ts("Les flux de la journee", [sql("A", BILAN_FLUX)],
PG, 0, 14, 24, 10, "watt",
desc="Les cinq puissances sur le meme axe. La batterie est signee : "
"au-dessus de zero elle se remplit, en dessous elle porte la maison. "
"Quand « Reseau » decolle alors que la production est forte, c'est "
"une fermeture du contacteur qu'il faut expliquer."),
ts("Etat de charge, et ce que la nuit exigera", [
sql("A", "SELECT ts AS time, valeur AS \"SOC reel\"\n"
"FROM mesure WHERE cle = 'sensor.victron_battery_soc'\n"
" AND $__timeFilter(ts) ORDER BY 1"),
sql("B", "SELECT ts AS time, soc_requis_pct AS \"SOC requis par le bilan\"\n"
"FROM bilan WHERE $__timeFilter(ts) AND statut = 'ok' ORDER BY 1")],
PG, 0, 24, 12, 9, "percent",
desc="Le SOC reel contre ce que le bilan energetique reclame pour "
"traverser la nuit jusqu'au croisement production/consommation. "
"Quand le reel passe sous la ligne en fin de journee, le reseau sera "
"necessaire avant le matin."),
timeline("Reseau, source et decisions", [
sql("A", "SELECT ts AS time, source_mode AS \"Source\"\n"
"FROM decision WHERE $__timeFilter(ts) ORDER BY 1", fmt="table"),
sql("B", "SELECT ts AS time, etat AS \"Etat du controleur\"\n"
"FROM decision WHERE $__timeFilter(ts) ORDER BY 1", fmt="table"),
sql("C", "SELECT ts AS time,\n"
" CASE WHEN pv_censure THEN 'production bridee'\n"
" ELSE 'production libre' END AS \"Production\"\n"
"FROM bilan WHERE $__timeFilter(ts) ORDER BY 1", fmt="table")],
PG, 12, 24, 12, 9,
desc="Trois bandes qui se lisent ensemble. « Production bridee » signale "
"que le solaire n'avait plus de preneur : l'energie manquante ce "
"moment-la a ete refusee, pas absente."),
table("Les charges du jour", [sql("A",
"SELECT to_char(debut AT TIME ZONE 'Europe/Paris', 'HH24:MI') AS \"Debut\",\n"
" coalesce(to_char(fin AT TIME ZONE 'Europe/Paris', 'HH24:MI'), 'en cours') AS \"Fin\",\n"
" coalesce(vehicule, '?') AS \"Vehicule\",\n"
" round(coalesce(energie_compteur_kwh, energie_kwh)::numeric, 2) AS \"Energie\",\n"
" round(coalesce(p_max_w, 0)::numeric) AS \"Pointe\",\n"
" coalesce(source_mode, '?') AS \"Source\"\n"
"FROM session_charge WHERE $__timeFilter(debut) ORDER BY debut DESC", fmt="table")],
PG, 0, 33, 12, 8,
desc="⚠️ L'etiquette du vehicule vient d'un selecteur manuel : elle est "
"fausse s'il n'a pas ete positionne au branchement. Les deux "
"voitures sont electriquement indiscernables (3 680 W contre "
"3 655 W a consigne egale).",
overrides=[sur("Energie", [{"id": "unit", "value": "kwatth"}]),
sur("Pointe", [{"id": "unit", "value": "watt"}])]),
table("Ce qui est arrive aujourd'hui", [sql("A",
"SELECT to_char(ts AT TIME ZONE 'Europe/Paris', 'HH24:MI:SS') AS \"Heure\",\n"
" genre AS \"Genre\", resume AS \"Evenement\"\n"
"FROM evenement WHERE $__timeFilter(ts) AND genre <> 'bilan'\n"
"ORDER BY ts DESC", fmt="table")],
PG, 12, 33, 12, 8,
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")
base = pathlib.Path('deploy/historisation/grafana/dashboards')
for nom, d in [('journee.json', journee),
('nuit.json', nuit), ('solaire.json', solaire),
('prevision.json', prevision),
('telephone.json', telephone),
('systeme.json', systeme),
('vehicules.json', vehicules),
('batteries.json', batteries)]:
(base / nom).write_text(json.dumps(d, indent=2, ensure_ascii=False) + '\n')
print(f"{nom:16s} {len(d['panels'])} panneaux")