Grafana : les deux bases se consultent, et les requetes sont verifiees

Trois tableaux de bord provisionnes depuis git, sur le modele de la regle R2
(le depot est la source de verite, l'interface ne peut pas les ecraser) :

  La nuit      SOC reel contre SOC exige par le bilan, et la marge entre les
               deux. C'est la question qui avait demande une nuit d'archeologie
               le 03/09.
  Le solaire   l'excedent (§36), la censure, et le mode declare par chacun des
               sept chargeurs (§37) — le signal le plus direct dont on dispose.
  Prevision    l'ecart heure par heure, avec la bande de censure dessous : une
               production sous la prevision peut vouloir dire mauvaise
               prevision OU absence de preneur.

Les evenements de la table `evenement` deviennent des marqueurs verticaux sur
tous les graphiques — une courbe sans ses evenements ne s'interprete pas.

tools/verifier-dashboards.mjs rejoue CHAQUE requete pour de vrai (SQL contre
PostgreSQL, Flux contre InfluxDB, macros Grafana substituees), annotations
comprises puisqu'elles s'executent sur tous les panneaux. Sans lui une requete
cassee ne se voit qu'a l'ecran, panneau par panneau. 19/19 au deploiement.

Port 3001 et non 3000 : sonde avant d'ecrire la ligne, quelque chose ecoute
deja sur 3000 — la lecon du 5432 deja pris par le PostgreSQL de DSM.

Connexion PostgreSQL par le role evcs_lecture : un tableau de bord ne doit pas
pouvoir ecrire dans la memoire. Aucun secret duplique, aucun secret commite :
le provisionnement les reference par $VARIABLE depuis les deux .env.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
Tallagrand Thierry
2026-09-04 08:06:25 +02:00
parent 931e07eca9
commit fe029cfdff
11 changed files with 2264 additions and 0 deletions

View File

@@ -0,0 +1,435 @@
# -*- 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 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 timeline(title, targets, ds, x, y, w, h, desc="", mappings=None):
return {"type": "state-timeline", "title": title, "description": desc,
"datasource": ds, "targets": targets,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"fieldConfig": {"defaults": {
"color": {"mode": "continuous-GrYlRd"},
"custom": {"lineWidth": 0, "fillOpacity": 80,
"insertNulls": False, "spanNulls": True},
"mappings": mappings or []}, "overrides": []},
"options": {"showValue": "auto", "mergeValues": True,
"alignValue": "left", "rowHeight": 0.85,
"legend": {"displayMode": "list", "placement": "bottom",
"showLegend": False},
"tooltip": {"mode": "single"}}}
def table(title, targets, ds, x, y, w, h, desc="", overrides=None):
return {"type": "table", "title": title, "description": desc,
"datasource": ds, "targets": targets,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"fieldConfig": {"defaults": {"custom": {"align": "left",
"cellOptions": {"type": "auto"}, "filterable": True}},
"overrides": overrides or []},
"options": {"showHeader": True, "cellHeight": "sm",
"footer": {"show": False}}}
def barchart(title, targets, ds, x, y, w, h, unit="", desc=""):
return {"type": "barchart", "title": title, "description": desc,
"datasource": ds, "targets": targets,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"fieldConfig": {"defaults": {"unit": unit,
"color": {"mode": "palette-classic"},
"custom": {"lineWidth": 1, "fillOpacity": 80,
"gradientMode": "none", "axisBorderShow": False}},
"overrides": []},
"options": {"orientation": "auto", "xTickLabelRotation": -45,
"showValue": "never", "stacking": "none",
"groupWidth": 0.7, "barWidth": 0.9,
"legend": {"displayMode": "list", "placement": "bottom",
"showLegend": True},
"tooltip": {"mode": "multi", "sort": "none"}}}
def dashboard(uid, title, desc, panels, refresh="1m", frm="now-24h"):
return {
"uid": uid, "title": title, "description": desc,
"tags": ["evcs"], "timezone": "Europe/Paris", "editable": True,
"schemaVersion": 39, "version": 1, "refresh": refresh,
"time": {"from": frm, "to": "now"},
"timepicker": {"refresh_intervals": ["30s", "1m", "5m", "15m", "1h"]},
"annotations": {"list": [
{"builtIn": 1, "datasource": {"type": "grafana", "uid": "-- Grafana --"},
"enable": True, "hide": True, "name": "Annotations & Alerts",
"iconColor": "rgba(0, 211, 255, 1)", "type": "dashboard"},
{"name": "Evenements", "datasource": PG, "enable": True,
"iconColor": "semi-dark-orange", "hide": False,
"target": {"format": "table", "rawQuery": True, "editorMode": "code",
"rawSql": "SELECT ts AS time, resume AS text, "
"genre || ',' || coalesce(source,'?') AS tags "
"FROM evenement WHERE $__timeFilter(ts) ORDER BY 1"}}]},
"panels": panels}
# =====================================================================
# 1. LA NUIT — la question a laquelle on ne savait pas repondre le 03/09
# =====================================================================
nuit = dashboard("evcs-nuit", "La nuit", (
"Ce que le bilan exigeait, contre ce que la batterie a reellement fait. "
"C'est la question qui a demande une nuit d'archeologie le 03/09 et qui "
"tient maintenant en un coup d'oeil."), [
ts("Couverture de la nuit", [
sql("A", "SELECT ts AS time,\n"
" soc_pct AS \"SOC reel\",\n"
" soc_requis_pct AS \"SOC requis (bilan)\"\n"
"FROM bilan\n"
"WHERE $__timeFilter(ts)\n"
"ORDER BY 1"),
sql("B", "SELECT ts AS time, valeur AS \"Cible du matin\"\n"
"FROM mesure\n"
"WHERE cle = 'input_number.victron_evcs_night_target_soc'\n"
" AND $__timeFilter(ts)\n"
"ORDER BY 1")],
PG, 0, 0, 16, 9, "percent",
"SOC requis = ce qu'il faut MAINTENANT pour tenir jusqu'au croisement "
"PV/conso. Il decroit avec la nuit qui se consomme. Quand la courbe reelle "
"passe dessous, la batterie ne passera pas la nuit sans reseau.",
extra={"defaults": {"min": 0, "max": 100},
"overrides": [
{"matcher": {"id": "byName", "options": "SOC reel"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}},
{"id": "custom.lineWidth", "value": 3}]},
{"matcher": {"id": "byName", "options": "SOC requis (bilan)"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "orange"}},
{"id": "custom.fillOpacity", "value": 0}]},
{"matcher": {"id": "byName", "options": "Cible du matin"},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "purple"}},
{"id": "custom.fillOpacity", "value": 0},
{"id": "custom.lineStyle",
"value": {"fill": "dash", "dash": [8, 6]}}]}]}),
ts("Marge (reel moins requis)", [
sql("A", "SELECT ts AS time, soc_pct - soc_requis_pct AS \"Marge\"\n"
"FROM bilan\n"
"WHERE $__timeFilter(ts) AND soc_requis_pct IS NOT NULL\n"
"ORDER BY 1")],
PG, 16, 0, 8, 9, "percent",
"Sous zero, il manque de l'energie pour finir la nuit. La nuit du "
"02-03/09 est restee negative de bout en bout, et personne ne le voyait.",
extra={"defaults": {
"color": {"mode": "thresholds"},
"custom": {"lineWidth": 2, "fillOpacity": 25, "showPoints": "never",
"gradientMode": "scheme", "spanNulls": True,
"thresholdsStyle": {"mode": "line"}},
"thresholds": {"mode": "absolute", "steps": [
{"color": "red", "value": None}, {"color": "green", "value": 0}]}},
"options": {"legend": {"showLegend": False},
"tooltip": {"mode": "single", "sort": "none"}}}),
timeline("Ce que le controleur a decide", [
sql("A", "SELECT ts AS time,\n"
" source_mode AS \"Source\",\n"
" etat AS \"Etat\",\n"
" CASE WHEN session_engagee THEN 'engagee' ELSE '-' END AS \"Session reseau\",\n"
" CASE WHEN takeover_reseau THEN 'demande' ELSE '-' END AS \"Reprise reseau\"\n"
"FROM decision\n"
"WHERE $__timeFilter(ts)\n"
"ORDER BY 1", fmt="table")],
PG, 0, 9, 24, 7,
"Une ligne par CHANGEMENT de decision, pas une toutes les 30 s. "
"La session reseau de nuit doit s'engager UNE fois et se relacher a la "
"fermeture de la fenetre : plusieurs bascules = un oscillateur.",
mappings=[{"type": "value", "options": {
"-": {"color": "dark-blue", "index": 0},
"engagee": {"color": "orange", "index": 1},
"demande": {"color": "semi-dark-orange", "index": 2}}}]),
ts("Les puissances de la nuit", [
sql("A", "SELECT ts AS time,\n"
" max(valeur) FILTER (WHERE cle = 'sensor.victron_grid_power_l1') AS \"Reseau\",\n"
" max(valeur) FILTER (WHERE cle = 'sensor.victron_battery_power') AS \"Batterie\",\n"
" max(valeur) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS \"Maison\",\n"
" max(valeur) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power') AS \"Voiture\"\n"
"FROM mesure\n"
"WHERE $__timeFilter(ts)\n"
"GROUP BY ts\n"
"ORDER BY ts")],
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("A", "SELECT ts AS time,\n"
" max(valeur) FILTER (WHERE cle = 'sensor.victron_pv_power_system') AS \"Production\",\n"
" max(valeur) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS \"Maison\",\n"
" max(valeur) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power') AS \"Voiture\",\n"
" max(valeur) FILTER (WHERE cle = 'sensor.victron_pv_power_system')\n"
" - max(valeur) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS \"Excedent\"\n"
"FROM mesure\n"
"WHERE $__timeFilter(ts)\n"
"GROUP BY ts\n"
"ORDER BY ts")],
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 sept 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 ts AS time,\n"
" max(valeur) FILTER (WHERE cle = 'sensor.victron_battery_soc') AS \"SOC\"\n"
"FROM mesure WHERE $__timeFilter(ts) GROUP BY ts ORDER BY ts"),
sql("B", "SELECT ts AS time,\n"
" max(valeur) FILTER (WHERE cle = 'sensor.victron_battery_current') AS \"Courant\"\n"
"FROM mesure WHERE $__timeFilter(ts) GROUP BY ts ORDER BY ts")],
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
),
produit AS (
-- Un echantillon toutes les 60 s : chaque point vaut 1/60 h.
SELECT date_trunc('hour', ts) AS heure,
sum(valeur) / 60.0 / 1000.0 AS kwh
FROM mesure
WHERE cle = 'sensor.victron_pv_power_system' AND $__timeFilter(ts)
GROUP BY 1
)
SELECT coalesce(prevu.heure, produit.heure) AS time,
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 1"""
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\", jour 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."),
])
base = pathlib.Path('deploy/historisation/grafana/dashboards')
for nom, d in [('nuit.json', nuit), ('solaire.json', solaire),
('prevision.json', prevision)]:
(base / nom).write_text(json.dumps(d, indent=2, ensure_ascii=False) + '\n')
print(f"{nom:16s} {len(d['panels'])} panneaux")

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env node
// Verifie que CHAQUE requete des tableaux de bord Grafana s'execute vraiment.
//
// Grafana n'annonce une requete cassee qu'a l'ecran, panneau par panneau. Ce
// controle rejoue les requetes SQL contre PostgreSQL et les requetes Flux
// contre InfluxDB, avec les macros substituees comme Grafana le fait.
//
// node tools/verifier-dashboards.mjs
//
// Sortie non nulle si une seule requete echoue.
import fs from 'node:fs';
import path from 'node:path';
import pg from 'pg';
const env = Object.fromEntries(
fs.readFileSync('.env', 'utf8').split('\n').filter(l => l.includes('='))
.map(l => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]));
const DEPUIS = new Date(Date.now() - 24 * 3600e3).toISOString();
const JUSQUA = new Date().toISOString();
// Les macros de Grafana, telles qu'il les developpe cote serveur.
const macros = (q) => q
.replace(/\$__timeFilter\(([^)]+)\)/g, `$1 BETWEEN '${DEPUIS}' AND '${JUSQUA}'`)
.replace(/\$__timeFrom\(\)/g, `'${DEPUIS}'`)
.replace(/\$__timeTo\(\)/g, `'${JUSQUA}'`);
const fluxVars = (q) => q
.replace(/v\.timeRangeStart/g, `${DEPUIS}`)
.replace(/v\.timeRangeStop/g, `${JUSQUA}`)
.replace(/v\.windowPeriod/g, '1m');
const client = new pg.Client({
host: '10.10.10.22', port: 5433, database: 'evcs',
user: 'evcs_lecture', password: env.PG_LECTURE_PASSWORD });
await client.connect();
async function flux(q) {
const r = await fetch('http://10.10.10.22:8086/api/v2/query?org=airtria', {
method: 'POST',
headers: { Authorization: `Token ${env.INFLUXDB_TOKEN}`,
'Content-Type': 'application/vnd.flux', Accept: 'application/csv' },
body: q });
const t = await r.text();
if (!r.ok) throw new Error(t.slice(0, 300));
return t.split('\n').filter(l => l.includes(',_result,')).length;
}
const dossier = 'deploy/historisation/grafana/dashboards';
let echecs = 0, total = 0;
for (const f of fs.readdirSync(dossier).filter(f => f.endsWith('.json')).sort()) {
const d = JSON.parse(fs.readFileSync(path.join(dossier, f), 'utf8'));
console.log(`\n=== ${d.title} (${f}) ===`);
// Les annotations sont des requetes comme les autres, et elles s'executent
// sur TOUS les panneaux : une annotation cassee casse tout le tableau.
const cibles = [];
for (const a of d.annotations.list) {
if (a.target?.rawSql) cibles.push({ ou: 'annotation ' + a.name, sql: a.target.rawSql });
}
for (const p of d.panels) {
for (const t of p.targets ?? []) {
if (t.rawSql) cibles.push({ ou: p.title, sql: t.rawSql });
else if (t.query) cibles.push({ ou: p.title, flux: t.query });
}
}
for (const c of cibles) {
total++;
try {
const n = c.sql ? (await client.query(macros(c.sql))).rows.length
: await flux(fluxVars(c.flux));
const vide = n === 0 ? ' (aucune ligne sur 24 h — normal si la mesure vient de demarrer)' : '';
console.log(` OK ${String(n).padStart(5)} lignes ${c.ou}${vide}`);
} catch (e) {
echecs++;
console.log(` ECHEC ${c.ou}\n ${String(e.message).split('\n')[0]}`);
}
}
}
await client.end();
console.log(`\n${total - echecs}/${total} requetes valides`);
process.exit(echecs ? 1 : 0);