Un quatrieme tableau, pour le telephone

L'application Grafana de l'App Store ne parle qu'a Grafana Cloud ; une instance
auto-hebergee se consulte au navigateur, epingle sur l'ecran d'accueil. Reste
que les trois tableaux existants sont dessines pour un ecran large.

Contrainte de conception, verifiee et notee dans le README : sous ~770 px,
Grafana passe CHAQUE panneau en pleine largeur et les empile. Le nombre de
panneaux devient le nombre d'ecrans a faire defiler. D'ou six panneaux
seulement, et les chiffres regroupes dans deux panneaux "stat" — plusieurs
valeurs dans un meme panneau restent cote a cote, la ou six panneaux separes
auraient fait six ecrans.

La reponse d'abord : batterie, marge (ce qu'elle a moins ce qu'il lui faut),
excedent. Puis l'energie a couvrir et l'heure du relais solaire. Puis les deux
courbes qui expliquent ces chiffres, la frise du reseau, et les huit derniers
evenements.

26/26 requetes verifiees contre les bases reelles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
Tallagrand Thierry
2026-09-04 09:02:28 +02:00
parent 64aee4bfcc
commit ea9248e949
3 changed files with 817 additions and 1 deletions

View File

@@ -73,6 +73,27 @@ def ts(title, targets, ds, x, y, w, h, unit="", desc="", extra=None, legend="bot
p[k] = v
return p
def stat(title, targets, ds, x, y, w, h, desc="", overrides=None, texte="auto"):
"""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": []}, "overrides": overrides or []},
"options": {"reduceOptions": {"calcs": ["lastNotNull"],
"fields": "", "values": False},
"orientation": "horizontal", "textMode": texte,
"colorMode": "value", "graphMode": "none",
"justifyMode": "center",
"percentChangeColorMode": "standard",
"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,
@@ -466,8 +487,119 @@ prevision = dashboard("evcs-prevision", "Prevision contre reel", (
"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}])]),
])
base = pathlib.Path('deploy/historisation/grafana/dashboards')
for nom, d in [('nuit.json', nuit), ('solaire.json', solaire),
('prevision.json', prevision)]:
('prevision.json', prevision),
('telephone.json', telephone)]:
(base / nom).write_text(json.dumps(d, indent=2, ensure_ascii=False) + '\n')
print(f"{nom:16s} {len(d['panels'])} panneaux")