diff --git a/HANDOFF.md b/HANDOFF.md index d64cfbe..5400ddf 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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* diff --git a/deploy/historisation/README.md b/deploy/historisation/README.md index 3ee1d06..a9a037b 100644 --- a/deploy/historisation/README.md +++ b/deploy/historisation/README.md @@ -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. diff --git a/deploy/historisation/compose.yaml b/deploy/historisation/compose.yaml index 91b66c9..ceaca4e 100644 --- a/deploy/historisation/compose.yaml +++ b/deploy/historisation/compose.yaml @@ -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: diff --git a/deploy/historisation/grafana/dashboards/nuit.json b/deploy/historisation/grafana/dashboards/nuit.json new file mode 100644 index 0000000..cbad955 --- /dev/null +++ b/deploy/historisation/grafana/dashboards/nuit.json @@ -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 + } + } + } + ] +} diff --git a/deploy/historisation/grafana/dashboards/prevision.json b/deploy/historisation/grafana/dashboards/prevision.json new file mode 100644 index 0000000..57e6f52 --- /dev/null +++ b/deploy/historisation/grafana/dashboards/prevision.json @@ -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 + } + } + } + ] +} diff --git a/deploy/historisation/grafana/dashboards/solaire.json b/deploy/historisation/grafana/dashboards/solaire.json new file mode 100644 index 0000000..e87c16f --- /dev/null +++ b/deploy/historisation/grafana/dashboards/solaire.json @@ -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" + } + } + } + ] +} diff --git a/deploy/historisation/grafana/provisioning/dashboards/fournisseur.yaml b/deploy/historisation/grafana/provisioning/dashboards/fournisseur.yaml new file mode 100644 index 0000000..661e6fb --- /dev/null +++ b/deploy/historisation/grafana/provisioning/dashboards/fournisseur.yaml @@ -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 diff --git a/deploy/historisation/grafana/provisioning/datasources/evcs.yaml b/deploy/historisation/grafana/provisioning/datasources/evcs.yaml new file mode 100644 index 0000000..75d4012 --- /dev/null +++ b/deploy/historisation/grafana/provisioning/datasources/evcs.yaml @@ -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 diff --git a/deploy/historisation/preparer-grafana.sh b/deploy/historisation/preparer-grafana.sh new file mode 100755 index 0000000..c9b6d9b --- /dev/null +++ b/deploy/historisation/preparer-grafana.sh @@ -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" diff --git a/tools/gen-grafana-dashboards.py b/tools/gen-grafana-dashboards.py new file mode 100644 index 0000000..05c7d12 --- /dev/null +++ b/tools/gen-grafana-dashboards.py @@ -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") diff --git a/tools/verifier-dashboards.mjs b/tools/verifier-dashboards.mjs new file mode 100644 index 0000000..d5d21ee --- /dev/null +++ b/tools/verifier-dashboards.mjs @@ -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);