Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z
1662 lines
90 KiB
Python
1662 lines
90 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
|
|
# =====================================================================
|
|
# Rendement journalier declare par chaque chargeur (MANUEL §0 : la production
|
|
# reelle du jour est leur SOMME, jamais un sous-tracker du 450/200 en plus).
|
|
RENDEMENTS_FLUX = (
|
|
'from(bucket: "evcs")\n'
|
|
' |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n'
|
|
' |> filter(fn: (r) => r._measurement == "kWh" and r._field == "value")\n'
|
|
' |> filter(fn: (r) => r.entity_id == "airtria_mppt1_yield_today"\n'
|
|
' or r.entity_id == "mppt_450_200_hq2449yr9pt_yield_today"\n'
|
|
' or r.entity_id == "v1_yield_today")\n'
|
|
' |> aggregateWindow(every: v.windowPeriod, fn: last, createEmpty: false)\n'
|
|
' |> map(fn: (r) => ({ r with chargeur:\n'
|
|
' if r.entity_id =~ /^airtria/ then "MPPT 250/70 sud-est"\n'
|
|
' else if r.entity_id =~ /^mppt_450/ then "RS 450/200 sud + ouest"\n'
|
|
' else "150/35 suiveur" }))\n'
|
|
' |> group()\n')
|
|
|
|
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 =~ /^v1_/ then "Suiveur"\n'
|
|
' else 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("Production du jour par chargeur", [
|
|
flux("A", RENDEMENTS_FLUX + ' |> keep(columns: ["_time", "_value", "chargeur"])\n'
|
|
' |> pivot(rowKey: ["_time"], columnKey: ["chargeur"], valueColumn: "_value")\n'
|
|
' |> sort(columns: ["_time"])'),
|
|
flux("B", RENDEMENTS_FLUX +
|
|
' |> keep(columns: ["_time", "_value", "chargeur"])\n'
|
|
' |> pivot(rowKey: ["_time"], columnKey: ["chargeur"], valueColumn: "_value")\n'
|
|
' |> sort(columns: ["_time"])\n'
|
|
' |> fill(usePrevious: true, column: "MPPT 250/70 sud-est")\n'
|
|
' |> fill(usePrevious: true, column: "RS 450/200 sud + ouest")\n'
|
|
' |> fill(usePrevious: true, column: "150/35 suiveur")\n'
|
|
' |> filter(fn: (r) => exists r["MPPT 250/70 sud-est"] and exists r["RS 450/200 sud + ouest"]\n'
|
|
' and exists r["150/35 suiveur"])\n'
|
|
' |> map(fn: (r) => ({ _time: r._time, Total: r["MPPT 250/70 sud-est"]\n'
|
|
' + r["RS 450/200 sud + ouest"] + r["150/35 suiveur"] }))')],
|
|
IN, 0, 23, 24, 8, "kwatth",
|
|
"Le rendement du jour que chaque chargeur declare lui-meme (yield_today), "
|
|
"remis a zero chaque nuit. C'est la production REELLE (MANUEL §0), la meme "
|
|
"que sensor.evcs_production_jour_kwh et que l'agent vocal. Le Total "
|
|
"n'apparait que si les trois chargeurs publient : un chargeur muet donne "
|
|
"un total partiel, qu'on ne trace pas. Les trois ne se remettent pas a "
|
|
"zero a la meme minute (vers 00:40). Le suiveur n'est exporte que depuis "
|
|
"le 25/09.",
|
|
extra={"overrides": [
|
|
{"matcher": {"id": "byName", "options": "Total"},
|
|
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "yellow"}},
|
|
{"id": "custom.lineWidth", "value": 3},
|
|
{"id": "custom.fillOpacity", "value": 0}]}]}),
|
|
|
|
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.)" }))
|
|
|
|
sv = base
|
|
|> filter(fn: (r) => r.entity_id == "v1_pv_bus_voltage"
|
|
or r.entity_id == "v1_pv_current"
|
|
or r.entity_id == "v1_pv_yield_power")
|
|
|> map(fn: (r) => ({ r with tracker: "Suiveur (3 pan.)" }))
|
|
|
|
union(tables: [rs, se, sv])
|
|
|> 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, et le suiveur de trois panneaux. Le suiveur "
|
|
"n'est exporte vers InfluxDB que depuis le 25/09 (il manquait a la liste "
|
|
"d'export de Home Assistant, et non faute de publier).",
|
|
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 ON (module) "
|
|
"CASE WHEN rang IS NULL THEN module "
|
|
"ELSE rang || ' \u00b7 ' || module END AS __text, "
|
|
"module AS __value FROM cellule ORDER BY module, ts DESC"),
|
|
}
|
|
|
|
# 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, rang 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, d.rang\n"
|
|
" FROM cellule c JOIN dernier d ON d.module = c.module AND d.ts = c.ts\n"
|
|
" GROUP BY c.module, d.ts, d.rang),\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.rang AS \"Rang\", 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. « Rang » est "
|
|
"l'adresse du module dans SA chaine au dernier releve — chaque "
|
|
"chaine numerote a partir de 1, l'identite reste le code-barres.",
|
|
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")
|