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

@@ -169,3 +169,82 @@ Les deux bases vivent dans des volumes Docker nommés, donc **hors du dépôt et
hors des sauvegardes Home Assistant**. Une mémoire non sauvegardée est une
mémoire qu'on perdra une fois — voir `HANDOFF.md` §34, qui raconte la même
histoire pour Home Assistant.
---
## Consultation — Grafana (ajouté le 04/09/2026)
### Ce qu'il y a à consulter, et où
| Base | Où | Ce qu'elle garde | Consultable dans Grafana |
|---|---|---|---|
| **PostgreSQL `evcs`** | 10.10.10.22:5433 | ce que le système **calcule** : bilan, décisions, prévision publiée, événements | oui |
| **InfluxDB `evcs`** | 10.10.10.22:8086 | ce que HA **observe**, ~1 point/s, 113 grandeurs | oui |
| SQLite de Home Assistant | 10.10.10.17 | son historique brut (20 j) et ses statistiques long terme (186 j) | **non — volontairement** |
Le PostgreSQL du port **5432** appartient à DSM et à ses services internes.
Ce n'est pas une base du projet ; ne pas s'y connecter.
L'historique de Home Assistant reste consulté **dans Home Assistant**. L'y
brancher demanderait de lire un fichier SQLite de 4,4 Go en mode WAL, à travers
un partage réseau, pendant que HA écrit dedans : c'est la meilleure façon de
lire une base incohérente, et le risque n'est pas payé en retour puisque tout
ce qui compte pour l'avenir arrive désormais dans InfluxDB à une résolution
bien meilleure.
### Mise en service
```bash
cd /volume2/projects/EVCS/EVCS/deploy/historisation
bash preparer-grafana.sh # génère le mot de passe admin, l'affiche une fois
docker compose up -d grafana
docker compose logs -f grafana
```
Puis **http://10.10.10.22:3001** — port 3001 et non 3000, occupé sur cette
machine par autre chose.
Mot de passe perdu :
`grep GRAFANA_ADMIN_PASSWORD deploy/historisation/.env`
### Les trois tableaux de bord
- **La nuit** — le SOC réel contre le SOC exigé par le bilan, la marge entre
les deux, la session réseau, et ce que la nuit a coûté. C'est la question qui
avait demandé une nuit d'archéologie le 03/09.
- **Le solaire** — production, maison, et surtout **l'excédent** (la grandeur
qui décide d'un démarrage depuis le 03/09), avec la censure du MPPT et le
mode déclaré par chacun des sept chargeurs.
- **Prévision contre réel** — le biais de forme de la prévision, question
ouverte qui demande des semaines de données.
Les événements de la table `evenement` apparaissent en **marqueurs verticaux
orange sur tous les graphiques** : une courbe sans ses événements ne
s'interprète pas.
### Modifier un tableau de bord
Ils viennent du dépôt git, comme le code des nodes n8n — et pour la même
raison : une modification faite dans l'interface serait écrasée au prochain
démarrage, en silence. Le générateur est `tools/gen-grafana-dashboards.py` :
```bash
python3 tools/gen-grafana-dashboards.py # réécrit les trois JSON
node tools/verifier-dashboards.mjs # rejoue CHAQUE requête pour de vrai
```
`verifier-dashboards.mjs` exécute les requêtes SQL contre PostgreSQL et les
requêtes Flux contre InfluxDB, macros de Grafana substituées. Sans lui, une
requête cassée ne se voit qu'à l'écran, panneau par panneau. Sortie non nulle
si une seule échoue.
Grafana relit le dossier toutes les 30 s : pas besoin de redémarrer.
Pour explorer librement sans rien casser : **« Save as… »** depuis n'importe
quel tableau de bord en crée une copie personnelle et modifiable.
### Ce que Grafana ne peut pas faire
Il ne **stocke** rien : il interroge les deux bases à la demande. Le perdre ne
perd aucune mesure. Il n'écrit rien non plus — sa connexion PostgreSQL utilise
le rôle `evcs_lecture`, en lecture seule.

View File

@@ -121,7 +121,56 @@ services:
- HISTORISE_INTERVAL_S=60
command: ["node", "tools/historise-evcs.mjs"]
# Consultation des DEUX bases au meme endroit. Grafana ne stocke rien de la
# mesure : il interroge PostgreSQL et InfluxDB a la demande. Le perdre ne
# perd aucune donnee — c'est une fenetre, pas un coffre.
grafana:
image: grafana/grafana-oss:11.3.0
container_name: evcs-grafana
restart: always
depends_on:
postgres:
condition: service_healthy
ports:
# 3001 et NON 3000 : quelque chose ecoute deja sur 3000 sur cette
# machine (verifie avant d'ecrire cette ligne, comme le port 5432 deja
# pris par le PostgreSQL interne de DSM).
- "3001:3000"
env_file:
# Deux fichiers : les secrets de la pile (mot de passe admin Grafana,
# mot de passe PostgreSQL) et ceux du depot (role de lecture, jeton
# InfluxDB). Aucun secret n'est duplique — c'etait le piege du
# POSTGRES_PASSWORD/PGPASSWORD tenus egaux a la main.
- .env
- ../../.env
environment:
- TZ=Europe/Paris
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
# Rien ne sort de la maison : ni telemetrie, ni verification de version,
# ni recuperation de plugins. Cette installation doit fonctionner
# pendant une coupure Internet d'orage (HANDOFF §32).
- GF_ANALYTICS_REPORTING_ENABLED=false
- GF_ANALYTICS_CHECK_FOR_UPDATES=false
- GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES=false
- GF_NEWS_NEWS_FEED_ENABLED=false
- GF_USERS_ALLOW_SIGN_UP=false
- GF_DATE_FORMATS_DEFAULT_TIMEZONE=Europe/Paris
volumes:
# Provisionnement en LECTURE SEULE : sources de donnees et tableaux de
# bord viennent du depot git, comme le code des nodes n8n (regle R2).
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
# Base interne de Grafana : sessions, preferences, copies de travail.
- evcs-grafana-data:/var/lib/grafana
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://localhost:3000/api/health || exit 1"]
interval: 1m
timeout: 10s
retries: 5
volumes:
evcs-pgdata:
evcs-influx-data:
evcs-influx-config:
evcs-grafana-data:

View File

@@ -0,0 +1,644 @@
{
"uid": "evcs-nuit",
"title": "La nuit",
"description": "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.",
"tags": [
"evcs"
],
"timezone": "Europe/Paris",
"editable": true,
"schemaVersion": 39,
"version": 1,
"refresh": "1m",
"time": {
"from": "now-24h",
"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": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-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": [
{
"type": "timeseries",
"title": "Couverture de la nuit",
"description": "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.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "time_series",
"rawQuery": true,
"editorMode": "code",
"rawSql": "SELECT ts AS time,\n soc_pct AS \"SOC reel\",\n soc_requis_pct AS \"SOC requis (bilan)\"\nFROM bilan\nWHERE $__timeFilter(ts)\nORDER BY 1"
},
{
"refId": "B",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "time_series",
"rawQuery": true,
"editorMode": "code",
"rawSql": "SELECT ts AS time, valeur AS \"Cible du matin\"\nFROM mesure\nWHERE cle = 'input_number.victron_evcs_night_target_soc'\n AND $__timeFilter(ts)\nORDER BY 1"
}
],
"gridPos": {
"x": 0,
"y": 0,
"w": 16,
"h": 9
},
"fieldConfig": {
"defaults": {
"unit": "percent",
"color": {
"mode": "palette-classic"
},
"custom": {
"lineWidth": 2,
"fillOpacity": 8,
"showPoints": "never",
"spanNulls": true,
"axisBorderShow": false,
"gradientMode": "opacity"
},
"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
]
}
}
]
}
]
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true,
"calcs": []
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"type": "timeseries",
"title": "Marge (reel moins requis)",
"description": "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.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "time_series",
"rawQuery": true,
"editorMode": "code",
"rawSql": "SELECT ts AS time, soc_pct - soc_requis_pct AS \"Marge\"\nFROM bilan\nWHERE $__timeFilter(ts) AND soc_requis_pct IS NOT NULL\nORDER BY 1"
}
],
"gridPos": {
"x": 16,
"y": 0,
"w": 8,
"h": 9
},
"fieldConfig": {
"defaults": {
"unit": "percent",
"color": {
"mode": "thresholds"
},
"custom": {
"lineWidth": 2,
"fillOpacity": 25,
"showPoints": "never",
"gradientMode": "scheme",
"spanNulls": true,
"thresholdsStyle": {
"mode": "line"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 0
}
]
}
},
"overrides": []
},
"options": {
"legend": {
"showLegend": false
},
"tooltip": {
"mode": "single",
"sort": "none"
}
}
},
{
"type": "state-timeline",
"title": "Ce que le controleur a decide",
"description": "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.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "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\"\nFROM decision\nWHERE $__timeFilter(ts)\nORDER BY 1"
}
],
"gridPos": {
"x": 0,
"y": 9,
"w": 24,
"h": 7
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-GrYlRd"
},
"custom": {
"lineWidth": 0,
"fillOpacity": 80,
"insertNulls": false,
"spanNulls": true
},
"mappings": [
{
"type": "value",
"options": {
"-": {
"color": "dark-blue",
"index": 0
},
"engagee": {
"color": "orange",
"index": 1
},
"demande": {
"color": "semi-dark-orange",
"index": 2
}
}
}
]
},
"overrides": []
},
"options": {
"showValue": "auto",
"mergeValues": true,
"alignValue": "left",
"rowHeight": 0.85,
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": false
},
"tooltip": {
"mode": "single"
}
}
},
{
"type": "timeseries",
"title": "Les puissances de la nuit",
"description": "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).",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "time_series",
"rawQuery": true,
"editorMode": "code",
"rawSql": "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\"\nFROM mesure\nWHERE $__timeFilter(ts)\nGROUP BY ts\nORDER BY ts"
}
],
"gridPos": {
"x": 0,
"y": 16,
"w": 12,
"h": 8
},
"fieldConfig": {
"defaults": {
"unit": "watt",
"color": {
"mode": "palette-classic"
},
"custom": {
"lineWidth": 2,
"fillOpacity": 8,
"showPoints": "never",
"spanNulls": true,
"axisBorderShow": false,
"gradientMode": "opacity"
}
},
"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"
}
}
]
}
]
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true,
"calcs": []
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"type": "timeseries",
"title": "Ce qu'il reste a couvrir",
"description": "\"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.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "time_series",
"rawQuery": true,
"editorMode": "code",
"rawSql": "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\"\nFROM bilan\nWHERE $__timeFilter(ts)\nORDER BY 1"
}
],
"gridPos": {
"x": 12,
"y": 16,
"w": 12,
"h": 8
},
"fieldConfig": {
"defaults": {
"unit": "kwatth",
"color": {
"mode": "palette-classic"
},
"custom": {
"lineWidth": 2,
"fillOpacity": 12,
"showPoints": "never",
"spanNulls": true,
"gradientMode": "opacity"
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true,
"calcs": []
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"type": "table",
"title": "Journal des evenements",
"description": "Une courbe sans ses evenements ne s'interprete pas. Ces lignes apparaissent aussi en marqueurs verticaux sur tous les graphiques.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "SELECT ts AS \"Quand\", genre AS \"Genre\", source AS \"Par\",\n resume AS \"Ce qui s'est passe\"\nFROM evenement\nWHERE $__timeFilter(ts)\nORDER BY ts DESC"
}
],
"gridPos": {
"x": 0,
"y": 24,
"w": 24,
"h": 8
},
"fieldConfig": {
"defaults": {
"custom": {
"align": "left",
"cellOptions": {
"type": "auto"
},
"filterable": true
}
},
"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
}
]
}
]
},
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {
"show": false
}
}
}
]
}

View File

@@ -0,0 +1,238 @@
{
"uid": "evcs-prevision",
"title": "Prevision contre reel",
"description": "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.",
"tags": [
"evcs"
],
"timezone": "Europe/Paris",
"editable": true,
"schemaVersion": 39,
"version": 1,
"refresh": "1m",
"time": {
"from": "now-24h",
"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": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-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": [
{
"type": "barchart",
"title": "Heure par heure : promis contre livre",
"description": "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.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "WITH du_matin AS (\n SELECT DISTINCT ON (jour) jour, publie_a, horaire\n FROM prevision\n WHERE publie_a::date = jour\n AND jour BETWEEN $__timeFrom()::date AND $__timeTo()::date\n ORDER BY jour, publie_a ASC\n),\nprevu AS (\n SELECT (jour + (h - 1) * interval '1 hour') AS heure,\n horaire[h] AS kwh\n FROM du_matin, generate_subscripts(horaire, 1) AS h\n),\nproduit AS (\n -- Un echantillon toutes les 60 s : chaque point vaut 1/60 h.\n SELECT date_trunc('hour', ts) AS heure,\n sum(valeur) / 60.0 / 1000.0 AS kwh\n FROM mesure\n WHERE cle = 'sensor.victron_pv_power_system' AND $__timeFilter(ts)\n GROUP BY 1\n)\nSELECT coalesce(prevu.heure, produit.heure) AS time,\n round(prevu.kwh::numeric, 2) AS \"Prevu le matin meme\",\n round(produit.kwh::numeric, 2) AS \"Reellement produit\"\nFROM prevu FULL OUTER JOIN produit USING (heure)\nWHERE coalesce(prevu.heure, produit.heure) BETWEEN $__timeFrom() AND $__timeTo()\nORDER BY 1"
}
],
"gridPos": {
"x": 0,
"y": 0,
"w": 24,
"h": 10
},
"fieldConfig": {
"defaults": {
"unit": "kwatth",
"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"
}
}
},
{
"type": "state-timeline",
"title": "Y avait-il un preneur ?",
"description": "Seules les heures 'libre' permettent de juger la prevision. Les heures bridees ne disent rien de la ressource disponible.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "SELECT ts AS time,\n CASE WHEN pv_censure THEN 'bridee (mesure inexploitable)'\n ELSE 'libre (mesure valide)' END AS \"Production\"\nFROM bilan WHERE $__timeFilter(ts) ORDER BY 1"
}
],
"gridPos": {
"x": 0,
"y": 10,
"w": 24,
"h": 5
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-GrYlRd"
},
"custom": {
"lineWidth": 0,
"fillOpacity": 80,
"insertNulls": false,
"spanNulls": true
},
"mappings": [
{
"type": "value",
"options": {
"libre (mesure valide)": {
"color": "green",
"index": 0
},
"bridee (mesure inexploitable)": {
"color": "orange",
"index": 1
}
}
}
]
},
"overrides": []
},
"options": {
"showValue": "auto",
"mergeValues": true,
"alignValue": "left",
"rowHeight": 0.85,
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": false
},
"tooltip": {
"mode": "single"
}
}
},
{
"type": "table",
"title": "Les previsions telles qu'elles ont ete publiees",
"description": "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.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "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\"\nFROM prevision\nWHERE $__timeFilter(publie_a)\nORDER BY publie_a DESC, jour"
}
],
"gridPos": {
"x": 0,
"y": 15,
"w": 24,
"h": 9
},
"fieldConfig": {
"defaults": {
"custom": {
"align": "left",
"cellOptions": {
"type": "auto"
},
"filterable": true
}
},
"overrides": []
},
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {
"show": false
}
}
}
]
}

View File

@@ -0,0 +1,516 @@
{
"uid": "evcs-solaire",
"title": "Le solaire",
"description": "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.",
"tags": [
"evcs"
],
"timezone": "Europe/Paris",
"editable": true,
"schemaVersion": 39,
"version": 1,
"refresh": "1m",
"time": {
"from": "now-24h",
"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": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-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": [
{
"type": "timeseries",
"title": "Production, maison, et ce qui reste",
"description": "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.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "time_series",
"rawQuery": true,
"editorMode": "code",
"rawSql": "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\"\nFROM mesure\nWHERE $__timeFilter(ts)\nGROUP BY ts\nORDER BY ts"
}
],
"gridPos": {
"x": 0,
"y": 0,
"w": 24,
"h": 9
},
"fieldConfig": {
"defaults": {
"unit": "watt",
"color": {
"mode": "palette-classic"
},
"custom": {
"lineWidth": 2,
"fillOpacity": 8,
"showPoints": "never",
"spanNulls": true,
"axisBorderShow": false,
"gradientMode": "opacity"
}
},
"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
}
]
}
]
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true,
"calcs": []
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"type": "state-timeline",
"title": "Production bridee ?",
"description": "Bridee = de la ressource solaire est REFUSEE faute de preneur. Charger la voiture pendant ces heures-la ne coute rien.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "SELECT ts AS time,\n CASE WHEN pv_censure THEN coalesce(motif_censure, 'bridee')\n ELSE 'libre' END AS \"Production\"\nFROM bilan\nWHERE $__timeFilter(ts)\nORDER BY 1"
}
],
"gridPos": {
"x": 0,
"y": 9,
"w": 12,
"h": 6
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-GrYlRd"
},
"custom": {
"lineWidth": 0,
"fillOpacity": 80,
"insertNulls": false,
"spanNulls": true
},
"mappings": [
{
"type": "value",
"options": {
"libre": {
"color": "green",
"index": 0
}
}
},
{
"type": "regex",
"options": {
"pattern": ".*",
"result": {
"color": "orange",
"index": 1
}
}
}
]
},
"overrides": []
},
"options": {
"showValue": "auto",
"mergeValues": true,
"alignValue": "left",
"rowHeight": 0.85,
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": false
},
"tooltip": {
"mode": "single"
}
}
},
{
"type": "state-timeline",
"title": "Ce que les chargeurs declarent eux-memes",
"description": "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.",
"datasource": {
"type": "influxdb",
"uid": "evcs-influx"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "evcs-influx"
},
"query": "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\")"
}
],
"gridPos": {
"x": 12,
"y": 9,
"w": 12,
"h": 6
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-GrYlRd"
},
"custom": {
"lineWidth": 0,
"fillOpacity": 80,
"insertNulls": false,
"spanNulls": true
},
"mappings": [
{
"type": "value",
"options": {
"mppt_active": {
"color": "green",
"index": 0
},
"voltage_current_limited": {
"color": "orange",
"index": 1
},
"off": {
"color": "dark-blue",
"index": 2
}
}
}
]
},
"overrides": []
},
"options": {
"showValue": "auto",
"mergeValues": true,
"alignValue": "left",
"rowHeight": 0.85,
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": false
},
"tooltip": {
"mode": "single"
}
}
},
{
"type": "timeseries",
"title": "Batterie",
"description": "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.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "time_series",
"rawQuery": true,
"editorMode": "code",
"rawSql": "SELECT ts AS time,\n max(valeur) FILTER (WHERE cle = 'sensor.victron_battery_soc') AS \"SOC\"\nFROM mesure WHERE $__timeFilter(ts) GROUP BY ts ORDER BY ts"
},
{
"refId": "B",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "time_series",
"rawQuery": true,
"editorMode": "code",
"rawSql": "SELECT ts AS time,\n max(valeur) FILTER (WHERE cle = 'sensor.victron_battery_current') AS \"Courant\"\nFROM mesure WHERE $__timeFilter(ts) GROUP BY ts ORDER BY ts"
}
],
"gridPos": {
"x": 0,
"y": 15,
"w": 12,
"h": 8
},
"fieldConfig": {
"defaults": {
"unit": "percent",
"color": {
"mode": "palette-classic"
},
"custom": {
"lineWidth": 2,
"fillOpacity": 8,
"showPoints": "never",
"spanNulls": true,
"axisBorderShow": false,
"gradientMode": "opacity"
},
"min": 0,
"max": 100
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Courant"
},
"properties": [
{
"id": "unit",
"value": "amp"
},
{
"id": "custom.axisPlacement",
"value": "right"
},
{
"id": "max",
"value": null
},
{
"id": "min",
"value": null
},
{
"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
}
]
}
]
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true,
"calcs": []
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"type": "timeseries",
"title": "Le detail que PostgreSQL ne voit pas (~1 point/s)",
"description": "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.",
"datasource": {
"type": "influxdb",
"uid": "evcs-influx"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "evcs-influx"
},
"query": "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\")"
}
],
"gridPos": {
"x": 12,
"y": 15,
"w": 12,
"h": 8
},
"fieldConfig": {
"defaults": {
"unit": "watt",
"color": {
"mode": "palette-classic"
},
"custom": {
"lineWidth": 1,
"fillOpacity": 5,
"showPoints": "never",
"spanNulls": true
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true,
"calcs": []
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
}
]
}

View File

@@ -0,0 +1,23 @@
# Les tableaux de bord viennent du depot git, comme le code des nodes n8n.
#
# `allowUiUpdates: false` : on ne peut pas ecraser depuis l'interface un
# tableau de bord versionne — c'est la meme regle que « JAMAIS editer le JSON
# du workflow ni l'UI n8n » (AGENTS.md R2), et pour la meme raison : sinon la
# modification est perdue au redemarrage suivant, en silence.
#
# Explorer reste libre : « Save as… » depuis n'importe quel tableau de bord
# cree une copie personnelle, modifiable, dans le dossier General.
apiVersion: 1
providers:
- name: EVCS
orgId: 1
folder: EVCS
type: file
disableDeletion: false
allowUiUpdates: false
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false

View File

@@ -0,0 +1,53 @@
# Sources de donnees, versionnees dans git et appliquees au demarrage.
#
# Les secrets ne sont PAS ici : `$VARIABLE` est substitue par Grafana depuis
# l'environnement du conteneur, alimente par les deux `.env` (jamais commites).
# Ce fichier peut donc etre lu par n'importe qui sans rien reveler.
apiVersion: 1
datasources:
# ---------------------------------------------------------------
# Ce que le systeme CALCULE : bilan, decisions, prevision publiee,
# evenements. Relationnel, donc joignable.
# ---------------------------------------------------------------
- name: EVCS PostgreSQL
uid: evcs-pg
type: grafana-postgresql-datasource
access: proxy
# Nom de service du reseau interne : ni IP en dur, ni dependance au port
# publie sur l'hote.
url: postgres:5432
database: evcs
# Role EN LECTURE SEULE (plus un INSERT restreint a la table evenement).
# Un tableau de bord ne doit pas pouvoir ecrire dans la memoire.
user: evcs_lecture
secureJsonData:
password: $PG_LECTURE_PASSWORD
jsonData:
sslmode: disable
postgresVersion: 1600
timescaledb: false
isDefault: true
editable: false
# ---------------------------------------------------------------
# Ce que Home Assistant OBSERVE : chaque changement d'etat, a la
# resolution native (~1 Hz), y compris les entites auxquelles
# personne n'a encore pense. C'est ce qui manquait le 03/09, quand
# les modes des chargeurs MPPT se sont reveles etre le bon signal.
# ---------------------------------------------------------------
- name: EVCS InfluxDB
uid: evcs-influx
type: influxdb
access: proxy
url: http://influxdb:8086
jsonData:
version: Flux
organization: airtria
defaultBucket: evcs
httpMode: POST
secureJsonData:
token: $INFLUXDB_TOKEN
editable: false

View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Ajoute GRAFANA_ADMIN_PASSWORD à deploy/historisation/.env s'il en manque un.
#
# Ce mot de passe n'est pas de la même nature que les autres : les secrets des
# bases ne sont jamais tapés par personne (les conteneurs les lisent), celui-ci
# sert à VOUS connecter à l'interface. Il est donc généré ici — pas choisi, une
# phrase inventée est plus devinable qu'elle n'en a l'air — puis affiché une
# fois, et il reste relisible à tout moment dans le fichier.
#
# Usage : bash preparer-grafana.sh
set -euo pipefail
cd "$(dirname "$0")"
if [ ! -f .env ]; then
echo "REFUS : .env absent. Lancer d'abord bash preparer-env.sh"
exit 1
fi
if grep -q '^GRAFANA_ADMIN_PASSWORD=' .env; then
echo "GRAFANA_ADMIN_PASSWORD est déjà défini. Rien à faire."
echo
echo "Pour le relire : grep GRAFANA_ADMIN_PASSWORD deploy/historisation/.env"
exit 0
fi
MDP="$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-20)"
printf '\n# Connexion à l’interface Grafana (utilisateur : admin).\nGRAFANA_ADMIN_PASSWORD=%s\n' "$MDP" >> .env
chmod 600 .env
# Le rôle de lecture PostgreSQL et le jeton InfluxDB vivent dans le .env à la
# racine du dépôt ; le compose donne les DEUX fichiers à Grafana, donc rien à
# recopier ici. On vérifie seulement qu'ils y sont, sans jamais les afficher.
RACINE=../../.env
for V in PG_LECTURE_PASSWORD INFLUXDB_TOKEN; do
if grep -q "^$V=." "$RACINE" 2>/dev/null; then
echo " $V : présent dans $RACINE"
else
echo " $V : MANQUANT dans $RACINE — Grafana ne pourra pas lire cette base."
case "$V" in
PG_LECTURE_PASSWORD) echo " -> bash creer-role-lecture.sh" ;;
INFLUXDB_TOKEN) echo " -> bash exporter-jeton-influx.sh" ;;
esac
fi
done
echo
echo "Grafana : http://10.10.10.22:3001"
echo " utilisateur : admin"
echo " mot de passe : $MDP"
echo
echo "Noté dans deploy/historisation/.env (600, jamais commité)."
echo "Relisible par : grep GRAFANA_ADMIN_PASSWORD deploy/historisation/.env"