Le tableau annoncait 750 Wh la ou VRM affichait 8 kWh. Ce n'etait pas une erreur de mesure mais une erreur de SOURCE : j'integrais la puissance depuis le moment ou mon detecteur avait commence a fonctionner — 03:16 — pour une session ouverte a 01:25. Or la borne tient son propre compteur de session, `sensor.evcs_hq2239dnt7p_last_session_energy`, qui vaut exactement les 8,0 kWh de VRM et compte depuis le vrai debut. Nouvelle colonne `energie_station_kwh` : c'est elle qui alimente desormais les totaux et le graphique. Mon integration est conservee en colonne « kWh integres » : l'ecart entre les deux signale un demarrage ou un redemarrage du service en cours de session, ce qui est une information en soi. Et elle sert de secours si la borne ne publie rien. Cherche au passage, sans succes : VRM affiche « Vehicule : Guest EV » mais AUCUNE entite ne porte ce champ cote Home Assistant. « Guest EV » signifie d'ailleurs vehicule NON identifie — Victron ne sait pas non plus qui est branche. 50/53 : les trois requetes en echec portent sur la nouvelle colonne, que la migration creera au prochain demarrage du service. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
1067 lines
58 KiB
Python
1067 lines
58 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}" for c, n in colonnes)
|
|
groupes = ",\n".join(f" count({n}) OVER (ORDER BY t) AS g_{n}" for n in noms)
|
|
remplis = ",\n".join(
|
|
f" first_value({n}) OVER (PARTITION BY g_{n} ORDER BY t) 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, {', '.join(noms)},\n{groupes}\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"},
|
|
{"name": "Evenements", "datasource": 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": 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}" for c, n in ENERGIE_CLES) + """
|
|
FROM mesure WHERE $__timeFilter(ts) GROUP BY 1
|
|
),
|
|
par_minute AS (
|
|
SELECT axe.t, """ + ", ".join(f"releve.{n}" for _, n in ENERGIE_CLES) + """
|
|
FROM axe LEFT JOIN releve USING (t)
|
|
),
|
|
paliers AS (
|
|
SELECT t, """ + ", ".join(n for _, n in ENERGIE_CLES) + ",\n" + ",\n".join(
|
|
f" count({n}) OVER (ORDER BY t) AS g_{n}" for _, n in ENERGIE_CLES) + """
|
|
FROM par_minute
|
|
),
|
|
report AS (
|
|
SELECT t,
|
|
""" + ",\n".join(
|
|
f" first_value({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, 5,
|
|
"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, 5,
|
|
"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, 10, 5,
|
|
"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": 26, "titleSize": 11}),
|
|
|
|
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")],
|
|
IN, 19, 0, 5, 5,
|
|
"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)]),
|
|
|
|
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, 5, 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, 5, 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, 5, 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, 13, 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, 22, 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"}])]),
|
|
])
|
|
|
|
base = pathlib.Path('deploy/historisation/grafana/dashboards')
|
|
for nom, d in [('nuit.json', nuit), ('solaire.json', solaire),
|
|
('prevision.json', prevision),
|
|
('telephone.json', telephone),
|
|
('systeme.json', systeme),
|
|
('vehicules.json', vehicules)]:
|
|
(base / nom).write_text(json.dumps(d, indent=2, ensure_ascii=False) + '\n')
|
|
print(f"{nom:16s} {len(d['panels'])} panneaux")
|