Le proprietaire veut « dans Grafana le detail de chaque cellule pendant la charge ». Le detail existait deja ; ce qui manquait etait le « pendant la charge » — rien ne disait QUAND le module chargeait, et un ecart entre cellules ne veut rien dire hors de son courant : le 05/09 la cellule meneuse etait a 3555 mV sous 50 A et a 3496 mV sous 30 A, meme module, meme tension de parc. Le releve console porte le courant depuis le 17/09, mais l ingestion ecrivait `null` : elle ne lisait que les quinze tensions. Elle repere maintenant la colonne `courant_mA` PAR SON NOM — les US5000 et les US2000C n emettent pas les memes colonnes, et une position ferait lire la mauvaise grandeur des qu on deplace le cable d un pack a l autre. Absente des anciens fichiers, donc les lignes deja versees gardent leur courant inconnu. Deux panneaux au tableau « Les batteries ». « Sante des modules — tous, d un coup d oeil » : le resume qu on lit en premier, tous modules ensemble, la ou les autres panneaux demandent de choisir un module d avance. Ecart maintenant, ecart maximum sur la periode, cellule la plus haute et la plus basse, courant au moment du releve, date du dernier releve. Releve au deploiement : cinq modules vivants, ecarts de 1 a 3 mV — le parc restant est sain, les trois modules atteints sont sortis le 12/09. « Le module chargeait-il ? » : le courant du MODULE, lu dans la table des cellules et non dans celle du parc, ce qui permet de voir qu un module se charge moins que ses voisins. Verifie contre la base et pas seulement execute : 75/75 requetes valides ne prouve que l execution, donc les deux nouvelles ont ete rejouees avec la vraie fenetre et rendent bien les grandeurs voulues. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
1604 lines
87 KiB
Python
1604 lines
87 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Genere les tableaux de bord EVCS. Le JSON de Grafana est verbeux ; l'ecrire
|
|
a la main invite les fautes silencieuses. Ce generateur est rejouable."""
|
|
import json, pathlib
|
|
|
|
PG = {"type": "grafana-postgresql-datasource", "uid": "evcs-pg"}
|
|
IN = {"type": "influxdb", "uid": "evcs-influx"}
|
|
|
|
def sql(refid, q, fmt="time_series"):
|
|
return {"refId": refid, "datasource": PG, "format": fmt,
|
|
"rawQuery": True, "editorMode": "code", "rawSql": q}
|
|
|
|
def flux(refid, q):
|
|
return {"refId": refid, "datasource": IN, "query": q}
|
|
|
|
def sql_grandeurs(refid, colonnes, calculees=None, fmt="time_series"):
|
|
"""Rapproche plusieurs grandeurs de la table `mesure` sur un axe commun.
|
|
|
|
Deux difficultes, toutes deux resolues ici :
|
|
|
|
1. Le service horodate chaque mesure avec son propre `last_updated` — le
|
|
vrai instant de la mesure, ce qui est le bon choix. Mais deux grandeurs
|
|
ne partagent alors presque jamais le meme `ts` : un `GROUP BY ts` rend
|
|
une ligne par grandeur, avec une seule colonne remplie.
|
|
2. Le service n'ecrit une mesure que si elle a change. Un simple
|
|
regroupement a la minute laisse donc des trous, et toute colonne
|
|
CALCULEE (une difference, par exemple) n'existe que les minutes ou
|
|
toutes ses entrees ont bouge — 73 minutes sur 434 le 04/09.
|
|
|
|
D'ou le report de la derniere valeur connue, qui est aussi ce que fait le
|
|
controleur en temps reel : il decide sur la derniere valeur qu'il a lue.
|
|
L'astuce `count(x) OVER (ORDER BY t)` numerote les paliers entre deux
|
|
valeurs non nulles ; `first_value` reprend celle du debut du palier.
|
|
"""
|
|
noms = [n for _, n in colonnes]
|
|
par_minute = ",\n".join(
|
|
f" avg(valeur) FILTER (WHERE cle = '{c}') AS {n},\n"
|
|
f" (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = '{c}'))[1]"
|
|
f" AS d_{n}" for c, n in colonnes)
|
|
groupes = ",\n".join(
|
|
f" {n}, count(d_{n}) OVER (ORDER BY t) AS g_{n}" for n in noms)
|
|
remplis = ",\n".join(
|
|
f" coalesce({n}, first_value(d_{n}) OVER (PARTITION BY g_{n} ORDER BY t))"
|
|
f" AS {n}" for n in noms)
|
|
sorties = ",\n".join(f' {n} AS "{n.replace("_", " ")}"' for n in noms)
|
|
if calculees:
|
|
sorties += ",\n" + ",\n".join(f' {e} AS "{lib}"' for e, lib in calculees)
|
|
q = (f"WITH par_minute AS (\n"
|
|
f" SELECT date_trunc('minute', ts) AS t,\n{par_minute}\n"
|
|
f" FROM mesure WHERE $__timeFilter(ts) GROUP BY 1\n"
|
|
f"),\npaliers AS (\n SELECT t,\n{groupes},\n"
|
|
+ ",\n".join(f" d_{n}" for n in noms) + f"\n FROM par_minute\n"
|
|
f"),\nreport AS (\n SELECT t,\n{remplis}\n FROM paliers\n)\n"
|
|
f"SELECT t AS time,\n{sorties}\nFROM report ORDER BY t")
|
|
return sql(refid, q, fmt=fmt)
|
|
|
|
|
|
def ts(title, targets, ds, x, y, w, h, unit="", desc="", extra=None, legend="bottom"):
|
|
p = {"type": "timeseries", "title": title, "description": desc,
|
|
"datasource": ds, "targets": targets,
|
|
"gridPos": {"x": x, "y": y, "w": w, "h": h},
|
|
"fieldConfig": {"defaults": {
|
|
"unit": unit, "color": {"mode": "palette-classic"},
|
|
"custom": {"lineWidth": 2, "fillOpacity": 8, "showPoints": "never",
|
|
"spanNulls": True, "axisBorderShow": False,
|
|
"gradientMode": "opacity"}},
|
|
"overrides": []},
|
|
"options": {"legend": {"displayMode": "list", "placement": legend,
|
|
"showLegend": True, "calcs": []},
|
|
"tooltip": {"mode": "multi", "sort": "desc"}}}
|
|
if extra:
|
|
for k, v in extra.items():
|
|
if k == "defaults":
|
|
p["fieldConfig"]["defaults"].update(v)
|
|
elif k == "overrides":
|
|
p["fieldConfig"]["overrides"] = v
|
|
else:
|
|
p[k] = v
|
|
return p
|
|
|
|
def stat(title, targets, ds, x, y, w, h, desc="", overrides=None, texte="auto",
|
|
taille=None, mappings=None, champs=""):
|
|
"""Grands chiffres. Sur telephone, Grafana empile TOUS les panneaux en
|
|
pleine largeur sous ~770 px : mettre plusieurs valeurs dans UN panneau est
|
|
donc le seul moyen de les garder cote a cote et de limiter le defilement."""
|
|
return {"type": "stat", "title": title, "description": desc,
|
|
"datasource": ds, "targets": targets,
|
|
"gridPos": {"x": x, "y": y, "w": w, "h": h},
|
|
"fieldConfig": {"defaults": {
|
|
"color": {"mode": "thresholds"},
|
|
"thresholds": {"mode": "absolute",
|
|
"steps": [{"color": "text", "value": None}]},
|
|
"mappings": mappings or []}, "overrides": overrides or []},
|
|
"options": {"reduceOptions": {"calcs": ["lastNotNull"],
|
|
# "" = champs numeriques seulement.
|
|
# Les etats d'alarme sont du TEXTE :
|
|
# il faut "/.*/" pour les afficher.
|
|
"fields": champs, "values": False},
|
|
"orientation": "horizontal", "textMode": texte,
|
|
"colorMode": "value", "graphMode": "none",
|
|
"justifyMode": "center",
|
|
"percentChangeColorMode": "standard",
|
|
"noValue": "—",
|
|
**({"text": taille} if taille else {}),
|
|
"wideLayout": True}}
|
|
|
|
|
|
def timeline(title, targets, ds, x, y, w, h, desc="", mappings=None):
|
|
return {"type": "state-timeline", "title": title, "description": desc,
|
|
"datasource": ds, "targets": targets,
|
|
"gridPos": {"x": x, "y": y, "w": w, "h": h},
|
|
"fieldConfig": {"defaults": {
|
|
"color": {"mode": "continuous-GrYlRd"},
|
|
"custom": {"lineWidth": 0, "fillOpacity": 80,
|
|
"insertNulls": False, "spanNulls": True},
|
|
"mappings": mappings or []}, "overrides": []},
|
|
"options": {"showValue": "auto", "mergeValues": True,
|
|
"alignValue": "left", "rowHeight": 0.85,
|
|
"legend": {"displayMode": "list", "placement": "bottom",
|
|
"showLegend": False},
|
|
"tooltip": {"mode": "single"}}}
|
|
|
|
def table(title, targets, ds, x, y, w, h, desc="", overrides=None):
|
|
return {"type": "table", "title": title, "description": desc,
|
|
"datasource": ds, "targets": targets,
|
|
"gridPos": {"x": x, "y": y, "w": w, "h": h},
|
|
"fieldConfig": {"defaults": {"custom": {"align": "left",
|
|
"cellOptions": {"type": "auto"}, "filterable": True}},
|
|
"overrides": overrides or []},
|
|
"options": {"showHeader": True, "cellHeight": "sm",
|
|
"footer": {"show": False}}}
|
|
|
|
def barchart(title, targets, ds, x, y, w, h, unit="", desc="", overrides=None):
|
|
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": overrides or []},
|
|
"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",
|
|
jusqu="now", 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,
|
|
# `jusqu` depasse « maintenant » sur les tableaux qui montrent une
|
|
# prevision : sans cela la partie a venir tombe hors du cadre, et le
|
|
# tableau ne montre que du passe. Les requetes de MESURE bornent alors
|
|
# elles-memes leur fin a `now()` — voir energie_sql().
|
|
"time": {"from": frm, "to": jusqu},
|
|
"timepicker": {"refresh_intervals": ["30s", "1m", "5m", "15m", "1h"]},
|
|
"annotations": {"list": [
|
|
{"builtIn": 1, "datasource": {"type": "grafana", "uid": "-- Grafana --"},
|
|
"enable": True, "hide": True, "name": "Annotations & Alerts",
|
|
"iconColor": "rgba(0, 211, 255, 1)", "type": "dashboard"},
|
|
# Les evenements sont OPT-IN : `enable: False`. Ils rendent un
|
|
# graphique illisible des qu'ils sont nombreux — le 06/09, sept
|
|
# jours d'historique donnaient une foret de pointillies orange qui
|
|
# masquait les courbes. Le bouton « Evenements » en tete de tableau
|
|
# les rallume quand on veut le contexte d'un incident.
|
|
#
|
|
# Et meme allumes, on ecarte le genre `bilan` : a lui seul 26 % des
|
|
# lignes, presque toutes des bascules de « production bridee » qui
|
|
# changent plusieurs fois par heure. Ce qui merite un trait vertical
|
|
# est ce qui ARRIVE — une bascule du contacteur, un vehicule
|
|
# branche, un changement de mode de charge, un redemarrage, une
|
|
# intervention manuelle — pas un drapeau qui clignote.
|
|
{"name": "Evenements", "datasource": PG, "enable": False,
|
|
"iconColor": "semi-dark-orange", "hide": False,
|
|
"target": {"format": "table", "rawQuery": True, "editorMode": "code",
|
|
"rawSql": "SELECT ts AS time, resume AS text, "
|
|
"genre || ',' || coalesce(source,'?') AS tags "
|
|
"FROM evenement WHERE $__timeFilter(ts) "
|
|
"AND genre <> 'bilan' ORDER BY 1"}}]},
|
|
"panels": panels}
|
|
|
|
# La prevision LA PLUS RECENTE, mise a plat en (heure locale, kWh), et bornee
|
|
# aux heures STRICTEMENT a venir.
|
|
#
|
|
# ⚠️ L'heure EN COURS est volontairement exclue : elle est deja mesuree pour ses
|
|
# minutes ecoulees. Y superposer une barre de prevision d'heure pleine ferait
|
|
# compter deux fois la meme heure et donnerait un total faux.
|
|
PREVISION_A_VENIR = """
|
|
SELECT (c.jour + (u.i - 1) * interval '1 hour') AT TIME ZONE 'Europe/Paris' AS heure,
|
|
u.v AS kwh
|
|
FROM (SELECT DISTINCT ON (jour) jour, horaire FROM prevision
|
|
WHERE jour >= current_date ORDER BY jour, publie_a DESC) c,
|
|
unnest(c.horaire) WITH ORDINALITY AS u(v, i)
|
|
WHERE (c.jour + (u.i - 1) * interval '1 hour') AT TIME ZONE 'Europe/Paris'
|
|
> date_trunc('hour', now())
|
|
"""
|
|
|
|
|
|
# =====================================================================
|
|
# 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\",\n"
|
|
" (SELECT round(coalesce(sum(kwh), 0)::numeric, 1)\n"
|
|
" FROM (" + PREVISION_A_VENIR + ") p\n"
|
|
" WHERE p.heure < date_trunc('day', now()) + interval '1 day')\n"
|
|
" AS \"Soleil restant\"", 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. "
|
|
"« Soleil restant » est le seul chiffre de ce bandeau qui ne soit pas une "
|
|
"mesure : c'est la derniere prevision pour les heures qui restent "
|
|
"aujourd'hui, l'heure en cours exclue.",
|
|
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))}]),
|
|
sur("Soleil restant", [{"id": "unit", "value": "kwatth"},
|
|
{"id": "decimals", "value": 1},
|
|
{"id": "color",
|
|
"value": {"mode": "fixed", "fixedColor": "#9e9e9e"}}])]),
|
|
|
|
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"
|
|
" (SELECT round(coalesce(sum(kwh), 0)::numeric, 1)\n"
|
|
" FROM (" + PREVISION_A_VENIR + ") p\n"
|
|
" WHERE p.heure >= date_trunc('day', now()) + interval '1 day')\n"
|
|
" AS \"Soleil demain\"\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. "
|
|
"« Soleil demain » dit de combien la batterie pourra se refaire une fois "
|
|
"la nuit passee : c'est lui qui decide jusqu'ou on peut la laisser "
|
|
"descendre.",
|
|
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))}]),
|
|
sur("Soleil demain", [{"id": "unit", "value": "kwatth"},
|
|
{"id": "decimals", "value": 1},
|
|
{"id": "color",
|
|
"value": {"mode": "fixed", "fixedColor": "#9e9e9e"}}])]),
|
|
|
|
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.
|
|
|
|
|
|
def energie_sql(prevision=False):
|
|
"""Energie par periode. Avec `prevision`, une colonne « Encore attendu »
|
|
prolonge le graphique sur les heures a venir."""
|
|
return """WITH bornes AS (
|
|
-- ⚠️ LA MESURE NE PEUT PAS DEPASSER MAINTENANT. « La journee » s'etend
|
|
-- desormais jusqu'a la fin du jour pour montrer la prevision ; sans ce
|
|
-- garde-fou, le report de la derniere valeur connue (plus bas) remplirait les
|
|
-- heures a venir avec la puissance de l'instant et gonflerait l'energie.
|
|
SELECT $__timeFrom()::timestamptz AS d,
|
|
least($__timeTo()::timestamptz, now()) AS f
|
|
),
|
|
-- Le pas s'adapte a la plage choisie. Sans cela, « 30 derniers jours » rend
|
|
-- 720 barres illisibles — c'est exactement ce que fait VRM en passant de
|
|
-- l'heure au jour puis au mois.
|
|
pas AS (
|
|
SELECT CASE WHEN f - d > interval '70 days' THEN 'month'
|
|
WHEN f - d > interval '4 days' THEN 'day'
|
|
ELSE 'hour' END AS unite,
|
|
CASE WHEN f - d > interval '70 days' THEN 'MM/YYYY'
|
|
WHEN f - d > interval '4 days' THEN 'DD/MM'
|
|
ELSE 'DD/MM HH24' END AS forme
|
|
FROM bornes
|
|
),
|
|
axe AS (
|
|
SELECT generate_series(date_trunc('minute', (SELECT d FROM bornes)),
|
|
date_trunc('minute', (SELECT f FROM bornes)),
|
|
interval '1 minute') AS t
|
|
),
|
|
releve AS (
|
|
SELECT date_trunc('minute', ts) AS t,
|
|
""" + ",\n".join(
|
|
f" avg(valeur) FILTER (WHERE cle = '{c}') AS {n},\n"
|
|
f" (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = '{c}'))[1] AS d_{n}"
|
|
for c, n in ENERGIE_CLES) + """
|
|
FROM mesure WHERE $__timeFilter(ts) GROUP BY 1
|
|
),
|
|
par_minute AS (
|
|
SELECT axe.t, """ + ", ".join(
|
|
f"releve.{n}, releve.d_{n}" for _, n in ENERGIE_CLES) + """
|
|
FROM axe LEFT JOIN releve USING (t)
|
|
),
|
|
paliers AS (
|
|
SELECT t, """ + ", ".join(
|
|
f"{n}, d_{n}" for _, n in ENERGIE_CLES) + ",\n" + ",\n".join(
|
|
f" count(d_{n}) OVER (ORDER BY t) AS g_{n}" for _, n in ENERGIE_CLES) + """
|
|
FROM par_minute
|
|
),
|
|
report AS (
|
|
SELECT t,
|
|
""" + ",\n".join(
|
|
f" coalesce({n}, first_value(d_{n}) OVER (PARTITION BY g_{n} ORDER BY t)) AS {n}"
|
|
for _, n in ENERGIE_CLES) + """
|
|
FROM paliers
|
|
),
|
|
mesure_par_periode AS (
|
|
SELECT date_trunc(pas.unite, t) AS b,
|
|
""" + ",\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)
|
|
HAVING """ + " OR ".join(f"count({n}) > 0" for _, n in ENERGIE_CLES) + """
|
|
)
|
|
""" + ("""
|
|
, a_venir AS (
|
|
SELECT date_trunc((SELECT unite FROM pas), p.heure) AS b,
|
|
sum(p.kwh) AS kwh
|
|
FROM (""" + PREVISION_A_VENIR + """) p
|
|
WHERE p.heure >= (SELECT d FROM bornes)
|
|
-- Borne STRICTE : une periode qui commence pile a la fin de la fenetre
|
|
-- appartient a la suivante. Sans cela, « La journee » affiche une barre
|
|
-- de demain 00 h.
|
|
AND p.heure < $__timeTo()::timestamptz
|
|
GROUP BY 1
|
|
)
|
|
-- FULL OUTER : une periode a venir n'a AUCUNE ligne de mesure, une periode
|
|
-- passee n'a aucune prevision. Ni l'une ni l'autre ne doit disparaitre.
|
|
SELECT to_char(coalesce(m.b, a.b), (SELECT forme FROM pas)) AS "Heure",
|
|
""" + ",\n".join(f' m."{n}"' for _, n in ENERGIE_CLES) + """,
|
|
round(a.kwh::numeric, 2) AS "Encore attendu"
|
|
FROM mesure_par_periode m FULL OUTER JOIN a_venir a ON a.b = m.b
|
|
ORDER BY coalesce(m.b, a.b)""" if prevision else """
|
|
SELECT to_char(b, (SELECT forme FROM pas)) AS "Heure",
|
|
""" + ",\n".join(f' "{n}"' for _, n in ENERGIE_CLES) + """
|
|
FROM mesure_par_periode
|
|
ORDER BY b""")
|
|
|
|
|
|
ENERGIE_SQL = energie_sql()
|
|
|
|
# Detail par tracker. Les quatre entrees du RS 450/200 et le MPPT1 ne suivent
|
|
# pas la meme convention de nommage : deux branches, reunies puis pivotees.
|
|
TRACKERS_FLUX = '''import "strings"
|
|
|
|
base = from(bucket: "evcs")
|
|
|> range(start: -30m)
|
|
|> filter(fn: (r) => r._field == "value")
|
|
|> filter(fn: (r) => r._measurement == "V" or r._measurement == "A" or r._measurement == "W")
|
|
|> last()
|
|
|
|
rs = base
|
|
|> filter(fn: (r) => strings.containsStr(v: r.entity_id, substr: "pv_tracker_solar_"))
|
|
|> map(fn: (r) => ({ r with tracker:
|
|
if strings.containsStr(v: r.entity_id, substr: "solar_1") then "Sud 1 (10 pan.)"
|
|
else if strings.containsStr(v: r.entity_id, substr: "solar_2") then "Sud 2 (10 pan.)"
|
|
else if strings.containsStr(v: r.entity_id, substr: "solar_3") then "Sud 3 (10 pan.)"
|
|
else "Ouest (9 pan.)" }))
|
|
|
|
se = base
|
|
|> filter(fn: (r) => r.entity_id == "airtria_mppt1_pv_bus_voltage"
|
|
or r.entity_id == "airtria_mppt1_pv_current"
|
|
or r.entity_id == "airtria_mppt1_pv_yield_power")
|
|
|> map(fn: (r) => ({ r with tracker: "Sud-est (10 pan.)" }))
|
|
|
|
union(tables: [rs, se])
|
|
|> keep(columns: ["tracker", "_measurement", "_value"])
|
|
|> pivot(rowKey: ["tracker"], columnKey: ["_measurement"], valueColumn: "_value")
|
|
|> sort(columns: ["tracker"])'''
|
|
|
|
def col(nom, unite, largeur=None, dec=0):
|
|
props = [{"id": "unit", "value": unite}, {"id": "decimals", "value": dec}]
|
|
if largeur:
|
|
props.append({"id": "custom.width", "value": largeur})
|
|
return sur(nom, props)
|
|
|
|
|
|
# Selecteur de vue, dans l'esprit de VRM : la meme representation, restreinte
|
|
# aux grandeurs qui interessent. La valeur de chaque option est directement une
|
|
# alternative d'expression reguliere, consommee par la transformation
|
|
# « filterFieldsByName » des panneaux — d'ou le separateur « | ».
|
|
_VUES = [
|
|
("Vue generale", "Solaire|Reseau|Batterie|Maison|Voiture"),
|
|
("Consommation", "Maison|Voiture"),
|
|
("Reseau", "Reseau|Maison"),
|
|
("Batterie", "Batterie|Solaire|Maison"),
|
|
("Chargeur VE", "Voiture|Solaire"),
|
|
]
|
|
VARIABLE_VUE = {
|
|
"name": "vue", "label": "Vue", "type": "custom", "hide": 0, "skipUrlSync": False,
|
|
"query": ",".join(f"{t} : {v}" for t, v in _VUES),
|
|
"options": [{"selected": i == 0, "text": t, "value": v} for i, (t, v) in enumerate(_VUES)],
|
|
"current": {"selected": True, "text": _VUES[0][0], "value": _VUES[0][1]},
|
|
"multi": False, "includeAll": False,
|
|
}
|
|
# « Time » avec une MAJUSCULE : c'est ainsi que la source PostgreSQL nomme le
|
|
# champ temporel dans le format time_series. Ecrit en minuscules, le filtre
|
|
# supprimait l'axe des temps et le panneau repondait « Data is missing a time
|
|
# field ». Les deux graphies sont acceptees, la casse etant significative ici.
|
|
FILTRE_VUE = [{"id": "filterFieldsByName",
|
|
"options": {"include": {"pattern": "^(Time|time|Heure|$vue)$"}}}]
|
|
|
|
systeme = dashboard("evcs-systeme", "Le systeme", (
|
|
"Replique locale de la vue generale VRM. La console Victron est hebergee "
|
|
"dans le cloud : elle disparait quand une coupure d'orage emporte "
|
|
"Internet. Tout ce qui est affiche ici est lu sur le reseau local."),
|
|
frm="now-24h", refresh="30s", variables=[VARIABLE_VUE], panels=[
|
|
|
|
stat("Reseau", [
|
|
# 6 h et non 24 : ces trois capteurs DISPARAISSENT quand AC-IN s'ouvre,
|
|
# laissant derriere eux une valeur qui ne vaut plus rien.
|
|
flux_dernier("A", "W", "victron_grid_power_l1", "Puissance", fenetre="6h"),
|
|
flux_dernier("B", "V", "airtria_mpii_v1_input_voltage_l1", "Tension", fenetre="6h"),
|
|
flux_dernier("C", "A", "gx_device_grid_current_l1", "Courant", fenetre="6h")],
|
|
IN, 0, 0, 5, 7,
|
|
"Positif : le reseau alimente la maison. Le contacteur EDF est pilote par "
|
|
"le workflow d'hysteresis, pas depuis ici.",
|
|
overrides=[col("Puissance", "watt"), col("Tension", "volt"), col("Courant", "amp", dec=1)]),
|
|
|
|
stat("Charges CA", [
|
|
flux_dernier("A", "W", "victron_consumption_power_l1", "Consommation"),
|
|
flux_dernier("B", "Hz", "airtria_mpii_v1_output_frequency_l1", "Frequence")],
|
|
IN, 5, 0, 4, 7,
|
|
"Ce que la maison consomme, voiture comprise.",
|
|
overrides=[col("Consommation", "watt"), col("Frequence", "hertz", dec=1)]),
|
|
|
|
stat("Batterie", [
|
|
flux_dernier("A", "%", "victron_battery_soc", "Charge"),
|
|
flux_dernier("B", "W", "victron_battery_power", "Puissance"),
|
|
flux_dernier("C", "V", "victron_battery_voltage", "Tension"),
|
|
flux_dernier("D", "A", "victron_battery_current", "Courant"),
|
|
flux_dernier("E", "°C", "victron_battery_temperature", "Temperature")],
|
|
IN, 9, 0, 9, 7,
|
|
"Puissance positive : la batterie se charge. Sept modules actifs sur huit, "
|
|
"un pack bloquant la charge vers 94 %.",
|
|
overrides=[
|
|
sur("Charge", [{"id": "unit", "value": "percent"}, {"id": "decimals", "value": 0},
|
|
{"id": "thresholds", "value": seuils(
|
|
("red", None), ("orange", 25), ("yellow", 40), ("green", 60))}]),
|
|
col("Puissance", "watt"), col("Tension", "volt", dec=2),
|
|
col("Courant", "amp", dec=1), col("Temperature", "celsius")],
|
|
# Cinq valeurs dans huit colonnes : Grafana reduisait le texte jusqu'a
|
|
# l'illisible. Dix colonnes et une taille imposee, plutot que de laisser
|
|
# l'ajustement automatique decider.
|
|
taille={"valueSize": 22, "titleSize": 11}),
|
|
|
|
dict(stat("Chargeur VE", [
|
|
flux_dernier("A", "W", "victron_evcs_ac_power", "Puissance"),
|
|
flux_dernier("B", "A", "victron_evcs_current", "Courant"),
|
|
flux_dernier("C", "A", "victron_evcs_set_current", "Consigne"),
|
|
sql("D", "-- Le compteur de la BORNE, repris de la table des sessions : il\n"
|
|
"-- compte depuis le vrai debut, la ou une integration ne couvre que\n"
|
|
"-- ce que le service a vu. Session close depuis plus de 2 h : rien.\n"
|
|
"SELECT round(coalesce(energie_station_kwh, energie_kwh)::numeric, 2) AS \"Session\"\n"
|
|
"FROM session_charge\n"
|
|
"WHERE fin > now() - interval '2 hours'\n"
|
|
"ORDER BY debut DESC LIMIT 1", fmt="table")],
|
|
IN, 18, 0, 6, 7,
|
|
"Consigne = ce que le controleur demande a la borne, reecrit toutes les "
|
|
"30 s. Courant = ce que la voiture prend reellement.",
|
|
overrides=[col("Puissance", "watt"), col("Courant", "amp", dec=1),
|
|
col("Consigne", "amp", dec=0),
|
|
sur("Session", [{"id": "unit", "value": "kwatth"},
|
|
{"id": "decimals", "value": 2},
|
|
{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}])],
|
|
taille={"valueSize": 22, "titleSize": 11}),
|
|
# Sources melangees : le panneau porte « Mixed », chaque requete la sienne.
|
|
datasource={"type": "datasource", "uid": "-- Mixed --"}),
|
|
|
|
stat("Chargeur PV", [
|
|
flux_dernier("A", "W", "victron_pv_power_system", "Production"),
|
|
flux_dernier("B", "W", "victron_pv_power_10m_avg", "Moyenne 10 min")],
|
|
IN, 0, 7, 5, 8,
|
|
"Rappel qui vaut pour toute lecture de cette valeur : sans injection, la "
|
|
"production MESUREE vaut min(ressource, consommation + absorption "
|
|
"batterie). Basse ne veut pas dire mauvais temps.",
|
|
overrides=[col("Production", "watt"), col("Moyenne 10 min", "watt")]),
|
|
|
|
dict(table("Detail par tracker", [flux("A", TRACKERS_FLUX)],
|
|
IN, 5, 7, 13, 8,
|
|
"Les 52 panneaux en quatre plans : sud-est 40 degres, trois chaines sud "
|
|
"35 degres, ouest 45 degres. Le suiveur de trois panneaux ne publie pas "
|
|
"de mode de fonctionnement et n'apparait donc pas ici.",
|
|
overrides=[sur("tracker", [{"id": "displayName", "value": "Tracker"},
|
|
{"id": "custom.width", "value": 200}]),
|
|
sur("V", [{"id": "displayName", "value": "Tension"},
|
|
{"id": "unit", "value": "volt"}, {"id": "decimals", "value": 0}]),
|
|
sur("A", [{"id": "displayName", "value": "Courant"},
|
|
{"id": "unit", "value": "amp"}, {"id": "decimals", "value": 1}]),
|
|
sur("W", [{"id": "displayName", "value": "Puissance"},
|
|
{"id": "unit", "value": "watt"}, {"id": "decimals", "value": 0},
|
|
{"id": "custom.cellOptions",
|
|
"value": {"type": "gauge", "mode": "gradient"}},
|
|
{"id": "max", "value": 3100}])]),
|
|
# `pivot` rend les colonnes par ordre alphabetique (A, V, W). On retablit
|
|
# l'ordre de lecture : tension, courant, puissance.
|
|
transformations=[{"id": "organize", "options": {
|
|
"indexByName": {"tracker": 0, "V": 1, "A": 2, "W": 3}}}]),
|
|
|
|
stat("Ce que le BMS impose", [
|
|
# Fenetre de 48 h pour ce panneau, et c'est deliberement different du
|
|
# reste. Un etat d'alarme ne devient jamais « indisponible » : il se
|
|
# publie. Sa derniere valeur EST donc la valeur courante, meme vieille de
|
|
# dix heures — a l'inverse du courant reseau, dont le capteur disparait et
|
|
# laisse derriere lui un chiffre qui ment.
|
|
flux_dernier("A", "A", "airtria_8_us2000c_maximum_allowed_charge_current", "Charge max", fenetre="48h"),
|
|
flux("B", 'from(bucket: "evcs")\n'
|
|
' |> range(start: -48h)\n'
|
|
' |> filter(fn: (r) => r._measurement == "sensor.airtria_8_us2000c_tension_de_cellule_elevee"'
|
|
' and r._field == "state")\n'
|
|
' |> last()\n'
|
|
' |> keep(columns: ["_value"])\n'
|
|
' |> rename(columns: {_value: "Tension cellule"})'),
|
|
flux("C", 'from(bucket: "evcs")\n'
|
|
' |> range(start: -48h)\n'
|
|
' |> filter(fn: (r) => r._measurement == "sensor.airtria_8_us2000c_cell_imbalance"'
|
|
' and r._field == "state")\n'
|
|
' |> last()\n'
|
|
' |> keep(columns: ["_value"])\n'
|
|
' |> rename(columns: {_value: "Desequilibre"})'),
|
|
flux_dernier("D", "%", "airtria_8_us2000c_state_of_health", "Sante", fenetre="48h")],
|
|
IN, 18, 7, 6, 8,
|
|
"« Charge max » est la limite que le BMS impose au systeme entier. Le "
|
|
"04/09 elle est tombee de 150 a 20 A en trois minutes, des 76 % de charge, "
|
|
"au moment ou l'alarme de tension de cellule s'est declenchee — le parc "
|
|
"n'etait alors qu'a 50,76 V pour 53,2 V autorises. Ce n'est pas le parc "
|
|
"qui sature, c'est une cellule.",
|
|
overrides=[col("Charge max", "amp"), col("Sante", "percent")],
|
|
taille={"valueSize": 24, "titleSize": 11},
|
|
champs="/.*/",
|
|
mappings=[{"type": "value", "options": {
|
|
"no_alarm": {"text": "OK", "color": "green", "index": 0},
|
|
"warning": {"text": "AVERTISSEMENT", "color": "orange", "index": 1},
|
|
"alarm": {"text": "ALARME", "color": "red", "index": 2}}}]),
|
|
|
|
ts("Les flux", [
|
|
# fmt="table" et non "time_series" : le filtre de vue s'applique bien sur
|
|
# une trame de type table — c'est ce que fait le graphique en barres — et
|
|
# echouait sur time_series avec « Data is missing a time field ». Le
|
|
# panneau time series accepte une trame table portant une colonne
|
|
# temporelle, le rendu est identique.
|
|
sql_grandeurs("A", [
|
|
('sensor.victron_pv_power_system', 'Solaire'),
|
|
('sensor.victron_grid_power_l1', 'Reseau'),
|
|
('sensor.victron_battery_power', 'Batterie'),
|
|
('sensor.victron_consumption_power_l1', 'Maison'),
|
|
('sensor.victron_evcs_ac_power', 'Voiture')], fmt="table")],
|
|
PG, 0, 15, 24, 9, "watt",
|
|
"Batterie positive : elle se charge. Reseau positif : il alimente. "
|
|
"Aux instants ou tout est a zero sauf la maison, c'est la batterie qui "
|
|
"porte tout.",
|
|
extra={"overrides": [
|
|
sur("Solaire", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "yellow"}}]),
|
|
sur("Reseau", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}]),
|
|
sur("Batterie", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "blue"}}]),
|
|
sur("Maison", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "text"}},
|
|
{"id": "custom.fillOpacity", "value": 0}]),
|
|
sur("Voiture", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}])],
|
|
"transformations": FILTRE_VUE}),
|
|
|
|
dict(barchart("Energie par periode", [sql("A", ENERGIE_SQL, fmt="table")],
|
|
PG, 0, 24, 24, 9, "kwatth",
|
|
"Le pas suit la periode choisie : l'heure jusqu'a 4 jours, le jour "
|
|
"jusqu'a 70, le mois au-dela. Obtenue en integrant la puissance, PAS en "
|
|
"lisant les compteurs kWh du GX : ceux-ci se remettent a zero sans le "
|
|
"signaler (verifie le 04/09, -373 kWh enregistres sur une heure cote "
|
|
"Home Assistant)."),
|
|
transformations=FILTRE_VUE),
|
|
])
|
|
|
|
|
|
# =====================================================================
|
|
# 6. LES VEHICULES — ce que chaque voiture encaisse, et sur quelle periode
|
|
# =====================================================================
|
|
# ⚠️ LIMITE STRUCTURELLE, a garder en tete a chaque lecture de ce tableau.
|
|
# L'etiquette du vehicule vient d'un SELECTEUR MANUEL. Elle est fiable quand
|
|
# le proprietaire l'a positionnee au branchement, et fausse s'il l'a oubliee ou
|
|
# si un redemarrage de Home Assistant l'a annulee (constate le 04/09 a 19:44).
|
|
#
|
|
# Il n'existe aucun moyen de la verifier :
|
|
# - electriquement, les deux voitures sont indiscernables. Mesure du 05/09 a
|
|
# consigne identique de 20 A : la VW accepte 3 680 W, la JEEP 3 655 W,
|
|
# soit 0,1 A d'ecart. Meme chargeur embarque de 3,7 kW, meme plateau.
|
|
# - horairement, la routine s'inverse le week-end : en semaine la JEEP charge
|
|
# le midi et la VW la nuit, mais le samedi la JEEP charge la nuit et la VW
|
|
# en journee. Une regle horaire serait fausse un week-end sur deux.
|
|
#
|
|
# D'ou la colonne « Doute » du tableau et le compteur d'etiquettes douteuses :
|
|
# on affiche l'incertitude au lieu de la masquer.
|
|
|
|
SESSIONS_PERIODE = """WITH bornes AS (
|
|
SELECT $__timeFrom()::timestamptz AS d, $__timeTo()::timestamptz AS f
|
|
),
|
|
pas AS (
|
|
SELECT CASE WHEN f - d > interval '70 days' THEN 'month'
|
|
WHEN f - d > interval '4 days' THEN 'day'
|
|
ELSE 'hour' END AS unite,
|
|
CASE WHEN f - d > interval '70 days' THEN 'MM/YYYY'
|
|
WHEN f - d > interval '4 days' THEN 'DD/MM'
|
|
ELSE 'DD/MM HH24' END AS forme
|
|
FROM bornes
|
|
)
|
|
SELECT to_char(date_trunc(pas.unite, debut), pas.forme) AS "Periode",
|
|
round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule = 'VW'), 0)::numeric, 2) AS "VW",
|
|
round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule = 'JEEP'), 0)::numeric, 2) AS "JEEP",
|
|
round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule IS NULL), 0)::numeric, 2) AS "Non etiquete"
|
|
FROM session_charge, pas
|
|
WHERE $__timeFilter(debut)
|
|
GROUP BY date_trunc(pas.unite, debut), pas.forme
|
|
ORDER BY date_trunc(pas.unite, debut)"""
|
|
|
|
vehicules = dashboard("evcs-vehicules", "Les vehicules", (
|
|
"Ce que chaque voiture encaisse, session par session et sur la periode "
|
|
"choisie. L'etiquette vient d'un selecteur manuel : elle peut etre fausse, "
|
|
"et rien ne permet de la verifier — les deux vehicules sont "
|
|
"electriquement indiscernables et leur routine s'inverse le week-end."),
|
|
frm="now-7d", refresh="5m", panels=[
|
|
|
|
stat("Sur la periode", [
|
|
sql("A",
|
|
"SELECT round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule = 'VW'), 0)::numeric, 1) AS \"VW\",\n"
|
|
" round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule = 'JEEP'), 0)::numeric, 1) AS \"JEEP\",\n"
|
|
" count(*) AS \"Sessions\",\n"
|
|
" count(*) FILTER (WHERE NOT vehicule_sur OR vehicule IS NULL) AS \"A confirmer\"\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 'confirme' ELSE 'a confirmer' END AS \"Etiquette\",\n"
|
|
" round((extract(epoch FROM fin - debut) / 60)::numeric) AS \"Minutes\",\n"
|
|
" round(coalesce(energie_station_kwh, energie_kwh)::numeric, 2) AS \"kWh\",\n"
|
|
" round(energie_kwh::numeric, 2) AS \"kWh integres\",\n"
|
|
" round(energie_reseau_kwh::numeric, 2) AS \"dont reseau\",\n"
|
|
" round(p_max_w::numeric) AS \"P max\",\n"
|
|
" consigne_max_a AS \"Consigne max\",\n"
|
|
" source_mode AS \"Source au depart\",\n"
|
|
" to_char(debranche_a, 'Dy HH24:MI') AS \"Debranche\"\n"
|
|
"FROM session_charge WHERE $__timeFilter(debut) ORDER BY debut DESC", fmt="table")],
|
|
PG, 0, 14, 24, 11,
|
|
"« kWh » vient du compteur de la BORNE, qui compte depuis le vrai debut "
|
|
"de la session. « kWh integres » est mon propre calcul, qui ne couvre que "
|
|
"ce que le service a vu — un ecart entre les deux signale un demarrage ou "
|
|
"un redemarrage en cours de session, pas une erreur de mesure. "
|
|
"« Debranche » confirme le vehicule apres coup : les deux partent a des "
|
|
"heures differentes. « dont reseau » se fonde sur le mode source declare "
|
|
"par le controleur — son intention — et non sur la puissance reseau "
|
|
"instantanee, qui peut alimenter la maison pendant que le soleil alimente "
|
|
"la voiture.",
|
|
overrides=[
|
|
sur("Vehicule", [{"id": "custom.width", "value": 100},
|
|
{"id": "custom.cellOptions", "value": {"type": "color-text"}},
|
|
{"id": "mappings", "value": [{"type": "value", "options": {
|
|
"VW": {"color": "blue", "index": 0},
|
|
"JEEP": {"color": "green", "index": 1},
|
|
"?": {"color": "orange", "index": 2}}}]}]),
|
|
sur("Doute", [{"id": "custom.width", "value": 150}]),
|
|
sur("kWh", [{"id": "unit", "value": "kwatth"}]),
|
|
sur("kWh integres", [{"id": "unit", "value": "kwatth"},
|
|
{"id": "custom.width", "value": 110}]),
|
|
sur("dont reseau", [{"id": "unit", "value": "kwatth"}]),
|
|
sur("P max", [{"id": "unit", "value": "watt"}])]),
|
|
])
|
|
|
|
# =====================================================================
|
|
# 7. LES BATTERIES — ce qu'aucun capteur Victron ne publie
|
|
# =====================================================================
|
|
# Le GX n'expose que l'agregat du parc : une tension, un courant, un SOC. Les
|
|
# quinze tensions de cellule d'un module ne sortent QUE par la console serie de
|
|
# son BMS. C'est pour cela que le desequilibre est reste invisible des annees.
|
|
|
|
VARIABLE_MODULE = {
|
|
"name": "module", "label": "Module", "type": "query", "datasource": PG,
|
|
"refresh": 1, "includeAll": False, "multi": False,
|
|
"query": "SELECT DISTINCT module FROM cellule ORDER BY 1",
|
|
}
|
|
|
|
# Echelle d'alarme du materiel, lue dans `sysinfo` du LVHUB le 05/09/2026.
|
|
# Ce ne sont pas des valeurs choisies ici.
|
|
SEUILS_CELLULE = seuils(("green", None), ("#EAB839", 3480), ("orange", 3520),
|
|
("red", 3550), ("dark-red", 3600))
|
|
|
|
batteries = dashboard("evcs-batteries", "Les batteries", (
|
|
"Les quinze tensions de cellule de chaque module Pylontech, relevees sur la "
|
|
"console de leur BMS. Aucune de ces valeurs n'existe cote Victron, qui ne "
|
|
"publie que l'agregat du parc. Les paliers de couleur sont ceux du materiel "
|
|
"lui-meme : 3480, 3520, 3550 et 3600 mV."), [
|
|
|
|
table("Sante des modules — tous, d'un coup d'oeil", [
|
|
sql("A",
|
|
"WITH vivants AS (\n"
|
|
" SELECT module FROM cellule GROUP BY module\n"
|
|
" HAVING max(ts) > now() - interval '2 hours'),\n"
|
|
"dernier AS (\n"
|
|
" SELECT DISTINCT ON (module) module, ts FROM cellule\n"
|
|
" WHERE module IN (SELECT module FROM vivants)\n"
|
|
" ORDER BY module, ts DESC),\n"
|
|
"maintenant AS (\n"
|
|
" SELECT c.module, max(c.mv) - min(c.mv) AS ecart,\n"
|
|
" max(c.mv) AS haute, min(c.mv) AS basse,\n"
|
|
" max(c.courant_ma) AS ma, d.ts\n"
|
|
" FROM cellule c JOIN dernier d ON d.module = c.module AND d.ts = c.ts\n"
|
|
" GROUP BY c.module, d.ts),\n"
|
|
"periode AS (\n"
|
|
" SELECT module, max(ecart) AS ecart_max FROM (\n"
|
|
" SELECT module, ts, max(mv) - min(mv) AS ecart\n"
|
|
" FROM cellule WHERE $__timeFilter(ts) GROUP BY module, ts) x\n"
|
|
" GROUP BY module)\n"
|
|
"SELECT m.module AS \"Module\",\n"
|
|
" m.ecart AS \"Ecart maintenant\",\n"
|
|
" p.ecart_max AS \"Ecart max sur la periode\",\n"
|
|
" m.haute AS \"Plus haute\", m.basse AS \"Plus basse\",\n"
|
|
" round(m.ma / 1000.0, 1) AS \"Courant (A)\",\n"
|
|
" to_char(m.ts, 'DD/MM HH24:MI') AS \"Dernier releve\"\n"
|
|
" FROM maintenant m LEFT JOIN periode p USING (module)\n"
|
|
" ORDER BY m.ecart DESC", fmt="table")],
|
|
PG, 0, 0, 24, 8,
|
|
desc="Le resume qu'on lit en premier, tous modules ensemble — les autres "
|
|
"panneaux demandent de choisir un module d'avance. L'ecart entre la "
|
|
"cellule la plus haute et la plus basse est l'indicateur qui resume "
|
|
"un module en un nombre : sous 20 mV il est sain, au-dela de 150 mV "
|
|
"le BMS reduit le courant de charge de TOUT le parc. La colonne "
|
|
"« Courant » dit si le module etait en charge au moment du releve : "
|
|
"un ecart ne se compare qu'a courant comparable.",
|
|
overrides=[
|
|
sur("Ecart maintenant", [{"id": "unit", "value": "mvolt"},
|
|
{"id": "custom.cellOptions",
|
|
"value": {"type": "color-background"}},
|
|
{"id": "thresholds", "value": seuils(
|
|
("green", None), ("#EAB839", 20),
|
|
("orange", 80), ("red", 150))}]),
|
|
sur("Ecart max sur la periode", [{"id": "unit", "value": "mvolt"},
|
|
{"id": "custom.cellOptions",
|
|
"value": {"type": "color-text"}},
|
|
{"id": "thresholds", "value": seuils(
|
|
("green", None), ("#EAB839", 50),
|
|
("orange", 150), ("red", 300))}]),
|
|
sur("Plus haute", [{"id": "unit", "value": "mvolt"},
|
|
{"id": "thresholds", "value": SEUILS_CELLULE},
|
|
{"id": "custom.cellOptions",
|
|
"value": {"type": "color-text"}}]),
|
|
sur("Plus basse", [{"id": "unit", "value": "mvolt"}]),
|
|
sur("Courant (A)", [{"id": "unit", "value": "amp"},
|
|
{"id": "decimals", "value": 1}])]),
|
|
|
|
ts("Cellule par cellule — $module", [
|
|
sql("A", "SELECT ts AS time, 'cellule ' || lpad(cellule::text, 2, '0') AS metric,\n"
|
|
" mv\n"
|
|
"FROM cellule\n"
|
|
"WHERE module = '$module' AND $__timeFilter(ts)\n"
|
|
"ORDER BY 1, 2")],
|
|
PG, 0, 8, 24, 10, unit="mvolt",
|
|
desc="Une courbe par cellule. Une cellule saine suit exactement ses "
|
|
"quatorze voisines ; une cellule qui a perdu de la capacite s'en "
|
|
"detache en haut de charge ET en bas de decharge, parce qu'elle se "
|
|
"remplit et se vide avant les autres.",
|
|
extra={"fieldConfig": {"defaults": {"custom": {"lineWidth": 1},
|
|
"thresholds": SEUILS_CELLULE,
|
|
"custom.thresholdsStyle": {"mode": "dashed"}}}},
|
|
legend="right"),
|
|
|
|
# ⚠️ Ces deux panneaux COMPARENT les modules entre eux : ils ne doivent
|
|
# montrer que les modules ENCORE SUR LA CHAINE. Le releveur lit aussi les
|
|
# modules isoles tant qu'ils sont sous tension — le pack ancien a cesse
|
|
# d'emettre le 05/09 mais restait affiche sur une fenetre de sept jours,
|
|
# Sans lui, « le detail de chaque cellule pendant la charge » n'est pas
|
|
# lisible : rien ne dit sur le panneau precedent QUAND le module chargeait.
|
|
# Or un ecart entre cellules ne veut rien dire hors de son courant — le 05/09
|
|
# la cellule meneuse etait a 3555 mV sous 50 A et a 3496 mV sous 30 A, meme
|
|
# module, meme tension de parc.
|
|
# ⚠️ Le courant est lu DANS la table des cellules, pas dans celle du parc :
|
|
# c'est le courant du MODULE, et c'est ce qui permet de voir qu'un module se
|
|
# charge moins que ses voisins. Il n'est renseigne que depuis le 17/09/2026 ;
|
|
# avant cette date la courbe est vide, et c'est un fait et non une panne.
|
|
ts("Le module chargeait-il ? — $module", [
|
|
sql("A", "SELECT ts AS time, 'courant du module' AS metric,\n"
|
|
" courant_ma / 1000.0 AS a\n"
|
|
"FROM cellule\n"
|
|
"WHERE module = '$module' AND cellule = 1\n"
|
|
" AND courant_ma IS NOT NULL AND $__timeFilter(ts)\n"
|
|
"ORDER BY 1")],
|
|
PG, 0, 18, 24, 5, unit="amp",
|
|
desc="Positif en charge, negatif en decharge. A lire en regard du panneau "
|
|
"au-dessus : l'ecart entre cellules s'ouvre avec le courant, et une "
|
|
"cellule abimee ne se trahit qu'en charge.",
|
|
extra={"defaults": {"custom": {"fillOpacity": 15, "lineWidth": 1}}}),
|
|
|
|
# soit huit courbes dont quatre mortes. Le critere est « a emis dans les
|
|
# deux dernieres heures », pas un prefixe de code-barres : rebrancher un
|
|
# pack le fait revenir tout seul.
|
|
ts("Ecart entre la plus haute et la plus basse", [
|
|
sql("A", "SELECT ts AS time, module AS metric, max(mv) - min(mv) AS ecart\n"
|
|
"FROM cellule WHERE $__timeFilter(ts)\n"
|
|
" AND module IN (SELECT module FROM cellule\n"
|
|
" GROUP BY module HAVING max(ts) > now() - interval '2 hours')\n"
|
|
"GROUP BY ts, module ORDER BY 1")],
|
|
PG, 0, 23, 12, 8, unit="mvolt",
|
|
desc="L'indicateur qui resume un module en une courbe. Un module sain de "
|
|
"ce parc tient sous 20 mV ; au-dela de 150 mV le BMS commence a "
|
|
"reduire le courant de charge de TOUT le parc."),
|
|
|
|
ts("La cellule meneuse, contre le plafond que le BMS impose", [
|
|
sql("A", "SELECT ts AS time, module || ' — cellule meneuse' AS metric,\n"
|
|
" max(mv) AS mv\n"
|
|
"FROM cellule WHERE $__timeFilter(ts)\n"
|
|
" AND module IN (SELECT module FROM cellule\n"
|
|
" GROUP BY module HAVING max(ts) > now() - interval '2 hours')\n"
|
|
"GROUP BY ts, module ORDER BY 1"),
|
|
sql("B", "SELECT ts AS time, 'plafond de charge (A)' AS metric,\n"
|
|
" valeur * 20 AS mv\n"
|
|
"FROM mesure\n"
|
|
"WHERE cle = 'sensor.airtria_8_us2000c_maximum_allowed_charge_current'\n"
|
|
" AND $__timeFilter(ts) ORDER BY 1")],
|
|
PG, 12, 23, 12, 8, unit="mvolt",
|
|
desc="Le plafond de charge est trace a l'echelle (20 mV par ampere) pour "
|
|
"tenir sur le meme axe. Le 05/09 a 13:05:08 la cellule meneuse est "
|
|
"passee de 3541 a 3555 mV ; cinq secondes plus tard le plafond "
|
|
"tombait de 50 a 30 A. Ensuite le courant baisse, la cellule se "
|
|
"detend, l'alarme repasse au vert — et le plafond, lui, reste bas.",
|
|
extra={"fieldConfig": {"defaults": {"thresholds": SEUILS_CELLULE}}}),
|
|
|
|
table("Amplitude de chaque cellule sur la periode", [
|
|
sql("A", "SELECT cellule AS \"Cellule\",\n"
|
|
" min(mv) AS \"Minimum\", max(mv) AS \"Maximum\",\n"
|
|
" max(mv) - min(mv) AS \"Amplitude\"\n"
|
|
"FROM cellule\n"
|
|
"WHERE module = '$module' AND $__timeFilter(ts)\n"
|
|
"GROUP BY cellule ORDER BY 4 DESC", fmt="table")],
|
|
PG, 0, 31, 12, 10,
|
|
desc="Le classement qui designe les cellules a traiter. Sur un module "
|
|
"sain les quinze amplitudes sont identiques a quelques millivolts "
|
|
"pres. Une amplitude de trois a quatre fois celle des voisines "
|
|
"designe une cellule qui a perdu sa capacite — et cela ne se "
|
|
"rattrape pas par un reequilibrage.",
|
|
overrides=[sur("Amplitude", [{"id": "custom.cellOptions",
|
|
"value": {"type": "color-background"}},
|
|
{"id": "unit", "value": "mvolt"},
|
|
{"id": "thresholds", "value": seuils(
|
|
("green", None), ("#EAB839", 250),
|
|
("orange", 400), ("red", 600))}]),
|
|
sur("Minimum", [{"id": "unit", "value": "mvolt"}]),
|
|
sur("Maximum", [{"id": "unit", "value": "mvolt"}])]),
|
|
|
|
ts("Etat de charge du parc pendant les relevés", [
|
|
sql("A", "SELECT ts AS time, valeur AS \"SOC du parc\"\n"
|
|
"FROM mesure WHERE cle = 'sensor.victron_battery_soc'\n"
|
|
" AND $__timeFilter(ts) ORDER BY 1"),
|
|
sql("B", "SELECT ts AS time, valeur AS \"Courant du parc\"\n"
|
|
"FROM mesure WHERE cle = 'sensor.victron_battery_current'\n"
|
|
" AND $__timeFilter(ts) ORDER BY 1")],
|
|
PG, 12, 31, 12, 10,
|
|
desc="Le contexte sans lequel un ecart entre cellules ne veut rien dire : "
|
|
"l'ecart grandit avec l'etat de charge, et l'excursion de la cellule "
|
|
"abimee grandit avec le courant. Le 05/09, a tension de parc egale, "
|
|
"la meneuse etait a 3555 mV sous 50 A et a 3496 mV sous 30 A."),
|
|
|
|
], frm="now-7d", variables=[VARIABLE_MODULE])
|
|
|
|
# =====================================================================
|
|
# 8. LA JOURNEE — le bilan condense, de minuit a maintenant
|
|
# =====================================================================
|
|
# Les sept autres tableaux sont thematiques : on y va quand on sait deja quoi
|
|
# chercher. Celui-ci repond a la question qu'on se pose en premier — « qu'est-ce
|
|
# qui s'est passe aujourd'hui ». D'ou la fenetre `now/d` (le jour civil en
|
|
# cours) et non `now-24h`, qui melangerait deux journees et rendrait le total
|
|
# de production incomparable d'un jour a l'autre.
|
|
#
|
|
# Toutes les energies sont obtenues en INTEGRANT la puissance, jamais en lisant
|
|
# les compteurs kWh du GX : ceux-ci se remettent a zero sans le signaler
|
|
# (verifie le 04/09, -373 kWh enregistres sur une heure cote Home Assistant).
|
|
|
|
BILAN_CLES = [
|
|
('sensor.victron_pv_power_system', 'pv'),
|
|
('sensor.victron_consumption_power_l1', 'maison'),
|
|
('sensor.victron_grid_power_l1', 'reseau'),
|
|
('sensor.victron_evcs_ac_power', 'voiture'),
|
|
('sensor.victron_battery_power', 'batterie'),
|
|
]
|
|
_bn = [n for _, n in BILAN_CLES]
|
|
|
|
# ⚠️ Les bornes de l'axe viennent des DONNEES (min/max de `mesure` dans la
|
|
# fenetre), pas des macros `$__timeFrom()` / `$__timeTo()`. Ce panneau rendait
|
|
# sept « — » a l'ecran alors que la requete donnait les bons totaux en psql et
|
|
# que Grafana ne journalisait aucune erreur : une ligne de NULL, donc un axe
|
|
# vide cote serveur. La seule macro utilisee ici est `$__timeFilter(ts)`, comme
|
|
# dans le panneau « Sur la periode » des vehicules, qui lui a toujours marche.
|
|
# C'est aussi plus juste physiquement : on n'extrapole pas au-dela de la
|
|
# derniere mesure observee.
|
|
BILAN_BASE = ("""WITH bornes AS (
|
|
SELECT min(ts) AS d, max(ts) AS f FROM mesure WHERE $__timeFilter(ts)
|
|
),
|
|
axe AS (
|
|
SELECT generate_series(date_trunc('minute', (SELECT d FROM bornes)),
|
|
date_trunc('minute', (SELECT f FROM bornes)),
|
|
interval '1 minute') AS t
|
|
),
|
|
releve AS (
|
|
SELECT date_trunc('minute', ts) AS t,
|
|
"""
|
|
+ ",\n".join(
|
|
f" avg(valeur) FILTER (WHERE cle = '{c}') AS {n},\n"
|
|
f" (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = '{c}'))[1] AS d_{n}"
|
|
for c, n in BILAN_CLES) + """
|
|
FROM mesure WHERE $__timeFilter(ts) GROUP BY 1
|
|
),
|
|
par_minute AS (
|
|
SELECT axe.t, """ + ", ".join(f"releve.{n}, releve.d_{n}" for n in _bn) + """
|
|
FROM axe LEFT JOIN releve USING (t)
|
|
),
|
|
paliers AS (
|
|
SELECT t, """ + ", ".join(f"{n}, d_{n}" for n in _bn) + ",\n"
|
|
+ ",\n".join(f" count(d_{n}) OVER (ORDER BY t) AS g_{n}" for n in _bn) + """
|
|
FROM par_minute
|
|
),
|
|
report AS (
|
|
SELECT t,
|
|
""" + ",\n".join(
|
|
f" coalesce({n}, first_value(d_{n}) OVER (PARTITION BY g_{n} ORDER BY t), 0)"
|
|
f" AS {n}" for n in _bn) + """
|
|
FROM paliers
|
|
)
|
|
""")
|
|
|
|
# Une seule ligne, six colonnes : c'est le resume que l'on lit en premier.
|
|
# L'autonomie se calcule sur la maison SANS la voiture, sinon une grosse charge
|
|
# solaire du vehicule gonfle artificiellement le taux.
|
|
# Le bilan sort en COLONNES : le tableau rend une ligne d'en-tetes et une ligne
|
|
# de valeurs, ce qui est la forme la plus lisible pour un resume.
|
|
#
|
|
# ⚠️ C'est un panneau `table`, PAS un `stat`. Quatre tentatives avec un `stat`
|
|
# sur exactement cette requete ont affiche sept « — » alors que l'API
|
|
# `/api/ds/query` montrait que le serveur lui renvoyait bien sept champs de type
|
|
# number avec leurs valeurs. Ce n'etait ni la requete, ni le cache, ni le
|
|
# provisioning (md5 du fichier servi et version en base verifies) : le panneau
|
|
# recevait les donnees et ne les rendait pas. Ne pas repasser en `stat`.
|
|
#
|
|
# ⚠️ Le `GROUP BY (1 = 1)` reste necessaire — un agregat global sans GROUP BY
|
|
# rendait un resultat vide cote Grafana. PostgreSQL refuse `GROUP BY true`
|
|
# (non-integer constant) mais accepte cette forme, qui n'impose aucune colonne
|
|
# dans le SELECT.
|
|
BILAN_TOTAUX = BILAN_BASE + """SELECT
|
|
round((sum(pv) / 60.0 / 1000.0)::numeric, 1) AS "Solaire produit",
|
|
round((sum(maison) / 60.0 / 1000.0)::numeric, 1) AS "Maison consommee",
|
|
round((sum(reseau) / 60.0 / 1000.0)::numeric, 1) AS "Reseau importe",
|
|
round((sum(voiture) / 60.0 / 1000.0)::numeric, 1) AS "Voiture",
|
|
round((sum(greatest(batterie, 0)) / 60.0 / 1000.0)::numeric, 1) AS "Batterie chargee",
|
|
round((sum(greatest(-batterie, 0)) / 60.0 / 1000.0)::numeric, 1) AS "Batterie rendue",
|
|
round((100.0 * greatest(sum(maison) - sum(reseau), 0)
|
|
/ nullif(sum(maison), 0))::numeric, 0) AS "Autonomie",
|
|
-- Ce qui reste a venir aujourd'hui, a cote de ce qui est deja fait. Sous-
|
|
-- requete independante de `report` : elle ne touche a aucun total mesure.
|
|
(SELECT round(coalesce(sum(kwh), 0)::numeric, 1)
|
|
FROM (""" + PREVISION_A_VENIR + """) p
|
|
WHERE p.heure < date_trunc('day', now()) + interval '1 day') AS "Reste attendu"
|
|
FROM report GROUP BY (1 = 1)"""
|
|
|
|
BILAN_FLUX = BILAN_BASE + """SELECT t AS time,
|
|
pv AS "Production solaire",
|
|
maison AS "Consommation maison",
|
|
voiture AS "Charge du vehicule",
|
|
reseau AS "Reseau",
|
|
batterie AS "Batterie (+ charge, - decharge)"
|
|
FROM report ORDER BY t"""
|
|
|
|
journee = dashboard(
|
|
'evcs-journee', 'La journee',
|
|
"Le bilan de la journee en cours, de minuit a maintenant : ce qui a ete "
|
|
"produit, ce qui a ete consomme, ce qui a ete achete, et ou c'est alle. "
|
|
"Les energies sont integrees depuis les puissances mesurees.",
|
|
[
|
|
table("Le bilan depuis minuit", [sql("A", BILAN_TOTAUX, fmt="table")],
|
|
PG, 0, 0, 24, 4,
|
|
desc="Une ligne d'en-tetes, une ligne de valeurs. L'autonomie est la part "
|
|
"de la consommation de la maison qui n'est PAS venue du reseau. "
|
|
"« Batterie rendue » est ce qu'elle a restitue, donc du solaire "
|
|
"differe et non une consommation supplementaire. « Reste attendu » "
|
|
"est la seule colonne qui ne soit pas une mesure : c'est ce que la "
|
|
"derniere prevision annonce pour les heures qui restent.",
|
|
overrides=[sur(n, [{"id": "unit", "value": "kwatth"},
|
|
{"id": "decimals", "value": 1}]) for n in
|
|
("Solaire produit", "Maison consommee", "Voiture",
|
|
"Batterie chargee", "Batterie rendue")]
|
|
+ [sur("Reseau importe", [{"id": "unit", "value": "kwatth"},
|
|
{"id": "decimals", "value": 1},
|
|
{"id": "custom.cellOptions",
|
|
"value": {"type": "color-text"}},
|
|
{"id": "thresholds", "value": seuils(
|
|
("green", None), ("#EAB839", 3),
|
|
("orange", 8))}]),
|
|
sur("Reste attendu", [{"id": "unit", "value": "kwatth"},
|
|
{"id": "decimals", "value": 1},
|
|
{"id": "custom.cellOptions",
|
|
"value": {"type": "color-text"}},
|
|
{"id": "color",
|
|
"value": {"mode": "fixed",
|
|
"fixedColor": "#9e9e9e"}}]),
|
|
sur("Autonomie", [{"id": "unit", "value": "percent"},
|
|
{"id": "decimals", "value": 0},
|
|
{"id": "custom.cellOptions",
|
|
"value": {"type": "color-background"}},
|
|
{"id": "thresholds", "value": seuils(
|
|
("red", None), ("#EAB839", 60),
|
|
("green", 85))}])]),
|
|
|
|
# ⚠️ PAS de FILTRE_VUE ici : cette transformation ne garde que les colonnes
|
|
# nommees par la variable $vue de « Le systeme ». Sur un tableau qui n'a pas
|
|
# cette variable, elle filtre TOUT et Grafana affiche « No numeric fields
|
|
# found ». Ici on veut les quatre colonnes, tout le temps.
|
|
barchart("Energie heure par heure",
|
|
[sql("A", energie_sql(prevision=True), fmt="table")],
|
|
PG, 0, 5, 24, 9, "kwatth",
|
|
"La forme de la journee : quand le solaire a couvert la maison, quand il "
|
|
"a fallu acheter — et, a la suite des barres mesurees, ce que la derniere "
|
|
"prevision annonce pour les heures qui restent. L'heure EN COURS n'a pas "
|
|
"de barre de prevision : elle est deja mesuree pour ses minutes ecoulees.",
|
|
overrides=[
|
|
# Gris clair et translucide : ce n'est pas une mesure, et le coup d'oeil
|
|
# doit le dire avant la legende.
|
|
sur("Encore attendu", [
|
|
{"id": "color", "value": {"mode": "fixed", "fixedColor": "#9e9e9e"}},
|
|
{"id": "custom.fillOpacity", "value": 35}])]),
|
|
|
|
ts("Les flux de la journee", [sql("A", BILAN_FLUX)],
|
|
PG, 0, 14, 24, 10, "watt",
|
|
desc="Les cinq puissances sur le meme axe. La batterie est signee : "
|
|
"au-dessus de zero elle se remplit, en dessous elle porte la maison. "
|
|
"Quand « Reseau » decolle alors que la production est forte, c'est "
|
|
"une fermeture du contacteur qu'il faut expliquer."),
|
|
|
|
ts("Etat de charge, et ce que la nuit exigera", [
|
|
sql("A", "SELECT ts AS time, valeur AS \"SOC reel\"\n"
|
|
"FROM mesure WHERE cle = 'sensor.victron_battery_soc'\n"
|
|
" AND $__timeFilter(ts) ORDER BY 1"),
|
|
sql("B", "SELECT ts AS time, soc_requis_pct AS \"SOC requis par le bilan\"\n"
|
|
"FROM bilan WHERE $__timeFilter(ts) AND statut = 'ok' ORDER BY 1")],
|
|
PG, 0, 24, 12, 9, "percent",
|
|
desc="Le SOC reel contre ce que le bilan energetique reclame pour "
|
|
"traverser la nuit jusqu'au croisement production/consommation. "
|
|
"Quand le reel passe sous la ligne en fin de journee, le reseau sera "
|
|
"necessaire avant le matin."),
|
|
|
|
timeline("Reseau, source et decisions", [
|
|
sql("A", "SELECT ts AS time, source_mode AS \"Source\"\n"
|
|
"FROM decision WHERE $__timeFilter(ts) ORDER BY 1", fmt="table"),
|
|
sql("B", "SELECT ts AS time, etat AS \"Etat du controleur\"\n"
|
|
"FROM decision WHERE $__timeFilter(ts) ORDER BY 1", fmt="table"),
|
|
sql("C", "SELECT ts AS time,\n"
|
|
" CASE WHEN pv_censure THEN 'production bridee'\n"
|
|
" ELSE 'production libre' END AS \"Production\"\n"
|
|
"FROM bilan WHERE $__timeFilter(ts) ORDER BY 1", fmt="table")],
|
|
PG, 12, 24, 12, 9,
|
|
desc="Trois bandes qui se lisent ensemble. « Production bridee » signale "
|
|
"que le solaire n'avait plus de preneur : l'energie manquante ce "
|
|
"moment-la a ete refusee, pas absente."),
|
|
|
|
table("Les charges du jour", [sql("A",
|
|
"SELECT to_char(debut AT TIME ZONE 'Europe/Paris', 'HH24:MI') AS \"Debut\",\n"
|
|
" coalesce(to_char(fin AT TIME ZONE 'Europe/Paris', 'HH24:MI'), 'en cours') AS \"Fin\",\n"
|
|
" coalesce(vehicule, '?') AS \"Vehicule\",\n"
|
|
" round(coalesce(energie_compteur_kwh, energie_kwh)::numeric, 2) AS \"Energie\",\n"
|
|
" round(coalesce(p_max_w, 0)::numeric) AS \"Pointe\",\n"
|
|
" coalesce(source_mode, '?') AS \"Source\"\n"
|
|
"FROM session_charge WHERE $__timeFilter(debut) ORDER BY debut DESC", fmt="table")],
|
|
PG, 0, 33, 12, 8,
|
|
desc="⚠️ L'etiquette du vehicule vient d'un selecteur manuel : elle est "
|
|
"fausse s'il n'a pas ete positionne au branchement. Les deux "
|
|
"voitures sont electriquement indiscernables (3 680 W contre "
|
|
"3 655 W a consigne egale).",
|
|
overrides=[sur("Energie", [{"id": "unit", "value": "kwatth"}]),
|
|
sur("Pointe", [{"id": "unit", "value": "watt"}])]),
|
|
|
|
table("Ce qui est arrive aujourd'hui", [sql("A",
|
|
"SELECT to_char(ts AT TIME ZONE 'Europe/Paris', 'HH24:MI:SS') AS \"Heure\",\n"
|
|
" genre AS \"Genre\", resume AS \"Evenement\"\n"
|
|
"FROM evenement WHERE $__timeFilter(ts) AND genre <> 'bilan'\n"
|
|
"ORDER BY ts DESC", fmt="table")],
|
|
PG, 12, 33, 12, 8,
|
|
desc="Le genre `bilan` est ecarte : ses bascules de production bridee "
|
|
"changent plusieurs fois par heure et noieraient le reste. Elles "
|
|
"sont lisibles dans la frise ci-dessus."),
|
|
], frm="now/d", jusqu="now/d")
|
|
|
|
|
|
base = pathlib.Path('deploy/historisation/grafana/dashboards')
|
|
for nom, d in [('journee.json', journee),
|
|
('nuit.json', nuit), ('solaire.json', solaire),
|
|
('prevision.json', prevision),
|
|
('telephone.json', telephone),
|
|
('systeme.json', systeme),
|
|
('vehicules.json', vehicules),
|
|
('batteries.json', batteries)]:
|
|
(base / nom).write_text(json.dumps(d, indent=2, ensure_ascii=False) + '\n')
|
|
print(f"{nom:16s} {len(d['panels'])} panneaux")
|