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

@@ -3438,3 +3438,92 @@ La premiere est plus propre — le role d'analyse reste strictement lecteur.
---
*Document mis a jour le : 04/09/2026 (~07:35) — InfluxDB en service, 249 entites, memoire complete*
## 45. Session du 04/09/2026 — Grafana : les deux bases se consultent
### Ce qu'il y a a consulter, et ce qu'il n'y a pas
Trois bases coexistent, et elles ne jouent pas le meme role :
| base | ou | contenu |
|---|---|---|
| PostgreSQL `evcs` | 10.10.10.22:**5433** | ce que le systeme CALCULE |
| InfluxDB `evcs` | 10.10.10.22:8086 | ce que HA OBSERVE, ~1 pt/s, 113 grandeurs |
| SQLite de HA | 10.10.10.17 | son historique brut (20 j) + statistiques (186 j) |
⚠️ Le PostgreSQL du port **5432** appartient a DSM. Ce n'est pas une base du
projet — la confusion « j'ai deux bases postgres » vient de la.
**La SQLite de HA n'est PAS branchee a Grafana, et c'est deliberé** : il
faudrait lire un fichier de 4,4 Go en mode WAL a travers un partage reseau
pendant que HA ecrit dedans (lecture incoherente garantie), pour un gain nul —
tout ce qui compte pour l'avenir arrive desormais dans InfluxDB a bien meilleure
resolution. L'historique de HA se consulte dans HA.
### Deploiement
Service `grafana` ajoute a `deploy/historisation/compose.yaml`, image
`grafana-oss:11.3.0`, publie sur **3001**.
⚠️ **3000 est deja occupe sur 10.10.10.22** — verifie par sonde AVANT d'ecrire
la ligne, exactement la lecon du 5432 deja pris par le PostgreSQL de DSM.
Telemetrie, verification de version et flux d'actualites desactives : cette
installation doit fonctionner pendant une coupure Internet d'orage (§32).
`bash preparer-grafana.sh` genere le mot de passe admin (genere, pas choisi),
l'affiche une fois, le laisse relisible dans le `.env` 600.
**Aucun secret n'est duplique** : le compose donne DEUX `env_file` a Grafana —
`.env` de la pile (mot de passe admin) et `../../.env` du depot
(`PG_LECTURE_PASSWORD`, `INFLUXDB_TOKEN`). Le provisionnement les reference par
`$VARIABLE`, donc les fichiers YAML de sources de donnees sont commitables tels
quels. C'etait le piege du POSTGRES_PASSWORD/PGPASSWORD tenus egaux a la main.
Connexion PostgreSQL par le role **`evcs_lecture`** : un tableau de bord ne doit
pas pouvoir ecrire dans la memoire.
### Provisionnement = git, comme les nodes n8n (regle R2)
`grafana/provisioning/` (sources + fournisseur) et `grafana/dashboards/`
(les trois JSON) sont montes en LECTURE SEULE, `allowUiUpdates: false`. Une
modification faite dans l'interface serait ecrasee au redemarrage, en silence —
meme raison que « JAMAIS editer l'UI n8n ». Le generateur rejouable est
`tools/gen-grafana-dashboards.py` ; Grafana relit le dossier toutes les 30 s.
« Save as… » reste libre pour explorer (copie personnelle dans General).
### `tools/verifier-dashboards.mjs` — l'equivalent des golden pour les tableaux
Rejoue **chaque** requete : SQL contre PostgreSQL, Flux contre InfluxDB, avec
les macros de Grafana (`$__timeFilter`, `$__timeFrom/To`, `v.timeRangeStart`,
`v.windowPeriod`) substituees comme il le fait. **Les annotations sont testees
aussi** : elles s'executent sur tous les panneaux, donc une annotation cassee
casse le tableau entier. Sortie non nulle au premier echec.
Sans lui, une requete cassee ne se voit qu'a l'ecran, panneau par panneau.
Etat au deploiement : **19/19**.
### Structure InfluxDB (relevee, a ne pas redecouvrir)
L'integration `influxdb` de HA n'ecrit PAS une mesure par entite :
- `_measurement` = **l'unite** (`W`, `A`, `V`, `%`, `kWh`, `°C`, `min`, `s`, `VA`, `Hz`, `°`)
pour tout capteur numerique ; tags `entity_id` (**sans le prefixe de domaine**),
`domain`, `installation` ; champ numerique `value`.
- `_measurement` = **l'entity_id complet** (`sensor.…`, `switch.…`) pour les
entites textuelles ; champ `state` = la chaine, `value` = 0/1 pour un switch.
Donc : `r._measurement == "W" and r.entity_id == "victron_pv_power_system" and r._field == "value"`,
et pour les modes des chargeurs `r._measurement =~ /operation_mode$/ and r._field == "state"`.
Les sept `*_operation_mode` (signal §37) et `switch.edf_general_ok_switch` sont
bien presents.
### Les trois tableaux
- **La nuit** (`evcs-nuit`) — SOC reel contre SOC exige par le bilan, **marge**
(rouge sous zero : la batterie ne passera pas), decisions en frise, puissances,
energie restant a couvrir, journal des evenements.
- **Le solaire** (`evcs-solaire`) — production / maison / voiture et surtout
**l'excedent** (§36), la censure avec son motif, **le mode declare par chacun
des sept chargeurs** (§37), et un panneau au pas natif InfluxDB pour montrer
ce que le pas de 60 s de PostgreSQL ne voit pas.
- **Prevision contre reel** (`evcs-prevision`) — l'ecart heure par heure entre
la prevision du matin meme et la production, **avec la bande de censure juste
dessous** : une production sous la prevision peut vouloir dire mauvaise
prevision OU absence de preneur, et seules les heures non bridees permettent
de juger. C'est le biais de forme identifie au §42, qui demande des semaines.
Les evenements apparaissent en marqueurs verticaux sur **tous** les graphiques.
---
*Document mis a jour le : 04/09/2026 (~08:30) — Grafana provisionne par git, 19/19 requetes verifiees*

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"

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);