Trois defauts signales par le proprietaire sur une capture prise a 00:04. 1. FENETRE GLISSANTE. La plage etait « aujourd'hui » : a 00:04 il n'y avait que quatre minutes a montrer. Passee a 24 h glissantes. Les etiquettes d'heure portent desormais le jour, sans quoi « 22 h » designerait deux heures differentes dans la meme fenetre. 2. VALEUR PERIMEE PRESENTEE COMME ACTUELLE — le vrai defaut. Le panneau Reseau affichait « Courant 2,2 A » : ecrit QUATORZE HEURES plus tot, du temps ou le contacteur etait ferme, alors qu'il est ouvert depuis 10:08. Cause : ma fenetre de lecture de 7 jours, posee pour eviter les cases vides. Les capteurs du reseau passent en `unavailable` quand AC-IN disparait ; ils ne tombent pas a zero. Leur derniere valeur numerique reste donc celle d'avant. Fenetre ramenee a 6 h : assez large pour couvrir une nuit sans soleil et une borne au repos — dont les zeros sont VRAIS — assez etroite pour ecarter une lecture de quatorze heures. 3. Une absence de mesure s'affiche desormais comme un tiret, jamais comme zero. Verifie apres coup sur les valeurs reelles : Reseau Courant = tiret (la valeur fausse a disparu), Chargeur PV = 0 W et Chargeur VE = 0 A conserves puisque justes, Batterie et Charges CA en direct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
871 lines
46 KiB
Python
871 lines
46 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):
|
|
"""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)
|
|
|
|
|
|
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"):
|
|
"""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",
|
|
"noValue": "—",
|
|
"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"):
|
|
return {
|
|
"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"):
|
|
"""Derniere valeur connue d'une entite, renommee pour l'affichage."""
|
|
# Fenetre de 6 HEURES : un compromis, et il merite d'etre explique.
|
|
#
|
|
# Trop large (7 jours, premiere version), le panneau Reseau affichait le
|
|
# 05/09 a minuit « Courant 2,2 A » — une valeur ecrite QUATORZE HEURES plus
|
|
# tot, du temps ou le contacteur etait ferme. Les capteurs du reseau
|
|
# passent en `unavailable` quand AC-IN disparait ; ils ne tombent pas a
|
|
# zero. Leur derniere valeur numerique reste donc celle d'avant, et elle
|
|
# ment.
|
|
#
|
|
# Trop etroite (30 minutes), on efface des valeurs JUSTES : la production
|
|
# nulle de la nuit est ecrite une fois au coucher du soleil et reste vraie
|
|
# jusqu'a l'aube.
|
|
#
|
|
# Six heures couvre une nuit sans soleil et une borne au repos, tout en
|
|
# ecartant une lecture vieille de quatorze heures. Au-dela, la case affiche
|
|
# un tiret — « aucune mesure recente », jamais un chiffre invente.
|
|
# Ancien commentaire :
|
|
# tentant — c'est ce que je faisais — mais le 05/09 a minuit le panneau
|
|
# Reseau affichait « Courant 2,2 A », valeur ecrite QUATORZE HEURES plus
|
|
# tot, du temps ou le contacteur etait ferme. Une case vide dit « aucune
|
|
# mesure recente » et c'est vrai ; un chiffre perime dit « voici la
|
|
# valeur » et c'est faux.
|
|
# Ancienne justification, conservee pour memoire : l'integration InfluxDB
|
|
# de Home Assistant n'ecrit QUE sur changement d'etat. Une grandeur stable
|
|
# — la borne a 0 A, voiture debranchee — n'a donc aucun point recent, et
|
|
# une fenetre courte affiche une case vide la ou la valeur est simplement
|
|
# constante. `last()` reste peu couteux sur une fenetre large.
|
|
return flux(refid,
|
|
f'from(bucket: "evcs")\n'
|
|
f' |> range(start: -6h)\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: ["_time", "_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 axe AS (
|
|
SELECT generate_series(date_trunc('minute', $__timeFrom()::timestamptz),
|
|
date_trunc('minute', $__timeTo()::timestamptz),
|
|
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(t, 'DD/MM HH24') AS "Heure",
|
|
""" + ",\n".join(
|
|
# Energie REELLEMENT accumulee : puissance moyenne x duree observee.
|
|
# Pas la moyenne seule, qui comptait 22 minutes comme 60 et annoncait
|
|
# 5,13 kWh de reseau a 01 h le 04/09 — le service n'avait demarre qu'a
|
|
# 01:37. Pas non plus un rejet des heures incompletes, qui laissait
|
|
# toujours vide l'heure EN COURS, celle qu'on regarde le plus.
|
|
# Contrepartie assumee : une heure passee amputee par un arret du service
|
|
# sous-estime — mais un tel arret est journalise et apparait en marqueur
|
|
# sur le graphique.
|
|
f' round((avg({n}) * count({n}) / 60.0 / 1000.0)::numeric, 2) AS "{n}"'
|
|
for _, n in ENERGIE_CLES) + """
|
|
FROM report
|
|
GROUP BY date_trunc('hour', t), to_char(t, 'DD/MM HH24')
|
|
HAVING """ + " OR ".join(f"count({n}) > 0" for _, n in ENERGIE_CLES) + """
|
|
ORDER BY date_trunc('hour', 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)
|
|
|
|
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", panels=[
|
|
|
|
stat("Reseau", [
|
|
flux_dernier("A", "W", "victron_grid_power_l1", "Puissance"),
|
|
flux_dernier("B", "V", "airtria_mpii_v1_input_voltage_l1", "Tension"),
|
|
flux_dernier("C", "A", "gx_device_grid_current_l1", "Courant")],
|
|
IN, 0, 0, 6, 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, 6, 0, 5, 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, 11, 0, 8, 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")]),
|
|
|
|
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, 6, 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, 6, 5, 18, 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}}}]),
|
|
|
|
ts("Les flux", [
|
|
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')])],
|
|
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"}}])]}),
|
|
|
|
barchart("Energie heure par heure", [sql("A", ENERGIE_SQL, fmt="table")],
|
|
PG, 0, 22, 24, 9, "kwatth",
|
|
"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)."),
|
|
])
|
|
|
|
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)]:
|
|
(base / nom).write_text(json.dumps(d, indent=2, ensure_ascii=False) + '\n')
|
|
print(f"{nom:16s} {len(d['panels'])} panneaux")
|