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

@@ -226,6 +226,16 @@ Mot de passe perdu :
mode déclaré par chacun des sept chargeurs. mode déclaré par chacun des sept chargeurs.
- **Prévision contre réel** — le biais de forme de la prévision, question - **Prévision contre réel** — le biais de forme de la prévision, question
ouverte qui demande des semaines de données. ouverte qui demande des semaines de données.
- **Sur le téléphone** — une seule colonne, six panneaux, et la réponse en
premier. À épingler sur l'écran d'accueil de l'iPhone :
`http://10.10.10.22:3001/d/evcs-telephone?kiosk` (Safari → Partager → Sur
l'écran d'accueil). `?kiosk` retire les menus.
⚠️ Contrainte de conception à connaître avant d'y ajouter quoi que ce soit :
sous ~770 px de large, Grafana passe **chaque** panneau en pleine largeur et
les empile. Le nombre de panneaux est donc le nombre d'écrans à faire
défiler — d'où les chiffres regroupés dans deux panneaux « stat » plutôt que
répartis en six.
Les événements de la table `evenement` apparaissent en **marqueurs verticaux Les événements de la table `evenement` apparaissent en **marqueurs verticaux
orange sur tous les graphiques** : une courbe sans ses événements ne orange sur tous les graphiques** : une courbe sans ses événements ne

View File

@@ -0,0 +1,674 @@
{
"uid": "evcs-telephone",
"title": "Sur le telephone",
"description": "Une colonne, pensee pour un ecran de telephone. Repond a une seule question : est-ce que la nuit qui vient va bien se passer ?",
"tags": [
"evcs"
],
"timezone": "Europe/Paris",
"editable": true,
"schemaVersion": 39,
"version": 1,
"refresh": "1m",
"time": {
"from": "now-12h",
"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"
},
{
"name": "Evenements",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"enable": true,
"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) ORDER BY 1"
}
}
]
},
"panels": [
{
"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.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"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\""
}
],
"gridPos": {
"x": 0,
"y": 0,
"w": 24,
"h": 5
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "text",
"value": null
}
]
},
"mappings": []
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Batterie"
},
"properties": [
{
"id": "unit",
"value": "percent"
},
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "orange",
"value": 25
},
{
"color": "yellow",
"value": 40
},
{
"color": "green",
"value": 60
}
]
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "Marge"
},
"properties": [
{
"id": "unit",
"value": "percent"
},
{
"id": "decimals",
"value": 1
},
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 0
}
]
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "Excedent"
},
"properties": [
{
"id": "unit",
"value": "watt"
},
{
"id": "decimals",
"value": 0
},
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{
"color": "blue",
"value": null
},
{
"color": "green",
"value": 0
}
]
}
}
]
}
]
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"orientation": "horizontal",
"textMode": "auto",
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"percentChangeColorMode": "standard",
"wideLayout": true
}
},
{
"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.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"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"
}
],
"gridPos": {
"x": 0,
"y": 5,
"w": 24,
"h": 5
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "text",
"value": null
}
]
},
"mappings": []
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "A couvrir"
},
"properties": [
{
"id": "unit",
"value": "kwatth"
}
]
},
{
"matcher": {
"id": "byName",
"options": "SOC requis"
},
"properties": [
{
"id": "unit",
"value": "percent"
},
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 60
},
{
"color": "orange",
"value": 85
},
{
"color": "red",
"value": 100
}
]
}
}
]
}
]
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"orientation": "horizontal",
"textMode": "auto",
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"percentChangeColorMode": "standard",
"wideLayout": true
}
},
{
"type": "timeseries",
"title": "Ce qu'elle a, ce qu'il lui faut",
"description": "Quand le vert passe sous l'orange, le reseau sera necessaire.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "time_series",
"rawQuery": true,
"editorMode": "code",
"rawSql": "SELECT ts AS time,\n soc_pct AS \"Batterie\",\n soc_requis_pct AS \"Requis\"\nFROM bilan WHERE $__timeFilter(ts) ORDER BY 1"
}
],
"gridPos": {
"x": 0,
"y": 10,
"w": 24,
"h": 7
},
"fieldConfig": {
"defaults": {
"unit": "percent",
"color": {
"mode": "palette-classic"
},
"custom": {
"lineWidth": 2,
"fillOpacity": 8,
"showPoints": "never",
"spanNulls": true,
"axisBorderShow": false,
"gradientMode": "opacity"
},
"min": 0,
"max": 100
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Batterie"
},
"properties": [
{
"id": "color",
"value": {
"mode": "fixed",
"fixedColor": "green"
}
},
{
"id": "custom.lineWidth",
"value": 3
}
]
},
{
"matcher": {
"id": "byName",
"options": "Requis"
},
"properties": [
{
"id": "color",
"value": {
"mode": "fixed",
"fixedColor": "orange"
}
},
{
"id": "custom.fillOpacity",
"value": 0
}
]
}
]
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true,
"calcs": []
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"type": "timeseries",
"title": "Ce qui reste apres la maison",
"description": "Au-dessus de zero, la voiture peut charger sans rien couter. En dessous, la charger revient a vider la batterie.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "time_series",
"rawQuery": true,
"editorMode": "code",
"rawSql": "WITH par_minute AS (\n SELECT date_trunc('minute', ts) AS t,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_pv_power_system') AS Production,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS Maison\n FROM mesure WHERE $__timeFilter(ts) GROUP BY 1\n),\npaliers AS (\n SELECT t, Production, Maison,\n count(Production) OVER (ORDER BY t) AS g_Production,\n count(Maison) OVER (ORDER BY t) AS g_Maison\n FROM par_minute\n),\nreport AS (\n SELECT t,\n first_value(Production) OVER (PARTITION BY g_Production ORDER BY t) AS Production,\n first_value(Maison) OVER (PARTITION BY g_Maison ORDER BY t) AS Maison\n FROM paliers\n)\nSELECT t AS time,\n Production AS \"Production\",\n Maison AS \"Maison\",\n Production - Maison AS \"Excedent\"\nFROM report ORDER BY t"
}
],
"gridPos": {
"x": 0,
"y": 17,
"w": 24,
"h": 7
},
"fieldConfig": {
"defaults": {
"unit": "watt",
"color": {
"mode": "palette-classic"
},
"custom": {
"lineWidth": 2,
"fillOpacity": 8,
"showPoints": "never",
"spanNulls": true,
"axisBorderShow": false,
"gradientMode": "opacity"
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Production"
},
"properties": [
{
"id": "color",
"value": {
"mode": "fixed",
"fixedColor": "yellow"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "Maison"
},
"properties": [
{
"id": "color",
"value": {
"mode": "fixed",
"fixedColor": "text"
}
},
{
"id": "custom.fillOpacity",
"value": 0
}
]
},
{
"matcher": {
"id": "byName",
"options": "Excedent"
},
"properties": [
{
"id": "color",
"value": {
"mode": "fixed",
"fixedColor": "green"
}
},
{
"id": "custom.lineWidth",
"value": 3
}
]
}
]
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true,
"calcs": []
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"type": "state-timeline",
"title": "Le reseau",
"description": "Une seule bascule par nuit : c'est le but de la session unique.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "SELECT ts AS time,\n source_mode AS \"Source\",\n CASE WHEN session_engagee THEN 'engagee' ELSE '-' END AS \"Session\"\nFROM decision WHERE $__timeFilter(ts) ORDER BY 1"
}
],
"gridPos": {
"x": 0,
"y": 24,
"w": 24,
"h": 5
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-GrYlRd"
},
"custom": {
"lineWidth": 0,
"fillOpacity": 80,
"insertNulls": false,
"spanNulls": true
},
"mappings": [
{
"type": "value",
"options": {
"-": {
"color": "dark-blue",
"index": 0
},
"engagee": {
"color": "orange",
"index": 1
}
}
}
]
},
"overrides": []
},
"options": {
"showValue": "auto",
"mergeValues": true,
"alignValue": "left",
"rowHeight": 0.85,
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": false
},
"tooltip": {
"mode": "single"
}
}
},
{
"type": "table",
"title": "Ce qui s'est passe",
"description": "Les huit derniers evenements, quelle que soit la fenetre de temps.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "SELECT to_char(ts, 'DD/MM HH24:MI') AS \"Quand\",\n resume AS \"Quoi\"\nFROM evenement ORDER BY ts DESC LIMIT 8"
}
],
"gridPos": {
"x": 0,
"y": 29,
"w": 24,
"h": 7
},
"fieldConfig": {
"defaults": {
"custom": {
"align": "left",
"cellOptions": {
"type": "auto"
},
"filterable": true
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Quand"
},
"properties": [
{
"id": "custom.width",
"value": 110
}
]
}
]
},
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {
"show": false
}
}
}
]
}

View File

@@ -73,6 +73,27 @@ def ts(title, targets, ds, x, y, w, h, unit="", desc="", extra=None, legend="bot
p[k] = v p[k] = v
return p 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): def timeline(title, targets, ds, x, y, w, h, desc="", mappings=None):
return {"type": "state-timeline", "title": title, "description": desc, return {"type": "state-timeline", "title": title, "description": desc,
"datasource": ds, "targets": targets, "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."), "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') base = pathlib.Path('deploy/historisation/grafana/dashboards')
for nom, d in [('nuit.json', nuit), ('solaire.json', solaire), 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') (base / nom).write_text(json.dumps(d, indent=2, ensure_ascii=False) + '\n')
print(f"{nom:16s} {len(d['panels'])} panneaux") print(f"{nom:16s} {len(d['panels'])} panneaux")