diff --git a/HANDOFF.md b/HANDOFF.md index 3decd4c..fe9c51e 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -3567,3 +3567,72 @@ savait de la nuit. --- *Document mis a jour le : 04/09/2026 (~09:00) — trois defauts de grandeur corriges dans les tableaux, 19/19* + +## 46. Session du 04/09/2026 — « Le systeme » : replique locale de la vue VRM + +### Pourquoi +La console VRM est hebergee dans le cloud Victron. Elle disparait exactement +quand on en a le plus besoin : une coupure d'orage sur ce site emporte souvent +Internet. Demande du proprietaire : la meme vision, en local. + +### Ou, et pourquoi pas ailleurs +Tout dans **Grafana**, rien dans Home Assistant. Le tableau Energie de HA +etait le candidat naturel pour la moitie basse (barres horaires) — il est +declare mais **entierement vide** (aucune source, aucun appareil). En verifiant +s'il pouvait servir, defaut majeur trouve : + +| compteur | etat | +|---|---| +| `gx_device_pv_energy` | **RESET non signale** : −373,35 kWh enregistres sur l'heure de 07:00 | +| `gx_device_dc_battery_charge/discharge_energy` | **RESET** : −134,7 et −142,6 kWh | +| `airtria_mppt1_total_yield`, `mppt_450_200_..._total_yield`, `v1_total_yield` | sains, monotones | +| `airtria_mpii_v1_energy_from_ac_in_1_to_*` et retours | sains, monotones | + +`state_class: total` sans `last_reset` : Home Assistant n'auto-detecte PAS la +remise a zero (c'est le comportement de `total_increasing`). Chaque redemarrage +de HA injecterait donc un trou geant dans les barres du tableau Energie. +=> Les energies du tableau Grafana sont obtenues **en integrant la puissance**, +methode immunisee contre ce probleme. Aucune ecriture dans HA. + +### Trois versions du calcul d'energie horaire avant d'arriver a la bonne +1. **Somme ponderee par l'intervalle** — laisse des TROUS : une grandeur qui ne + change pas n'est jamais reecrite, donc la production nulle de la nuit ne + produit qu'UNE ligne et les heures sans ligne sortaient vides au lieu de + zero. +2. **Moyenne des minutes presentes** — SUR-COMPTE : l'heure 01 annoncait + 5,13 kWh de reseau alors que le service n'avait demarre qu'a 01:37 — + 22 minutes comptees comme 60. +3. **Retenu** : axe des minutes complet (`generate_series`), report de la + derniere valeur connue, puis `avg x count / 60` = energie REELLEMENT + accumulee. Correct pour une heure pleine ET pour l'heure en cours, qu'un + simple rejet des heures incompletes laissait toujours vide — or c'est celle + qu'on regarde le plus. + +### Verification par recoupement avec la capture VRM +Total reseau du jour : **8,07 kWh** cote Grafana contre « Depuis reseau +9,0 kWh » cote VRM au meme moment. L'ecart de 0,9 kWh correspond exactement aux +treize minutes entre l'ouverture des heures creuses (01:24) et le demarrage du +service d'historisation (01:37). Detail par tracker recoupe aussi : la chaine +que j'ai etiquetee « Ouest » produit 132 W a 9 h contre 173-183 W pour les trois +chaines sud — et VRM montre bien Solar 4 comme la plus faible des quatre. + +### Pieges InfluxDB notes au passage +- L'integration `influxdb` de HA n'ecrit **que sur changement d'etat**. Une + fenetre `range(start: -30m)` laisse donc vides les cases dont la valeur est + stable — la borne a 0 A, voiture debranchee. Fenetre portee a **7 jours**, + `last()` reste peu couteux. +- `pivot` rend les colonnes par ordre alphabetique (A, V, W) : une + transformation `organize` retablit tension / courant / puissance. + +### Ce qui reste ouvert +- Le tableau Energie de HA pourrait etre configure avec les compteurs SAINS + (trois `*_total_yield` en sources solaires, quatre compteurs MultiPlus pour + le reseau). Il manquerait la batterie, faute de compteur monotone — a creer + par integration de Riemann sur `sensor.victron_battery_power` si on le veut. + Non fait : le tableau Grafana couvre le besoin sans rien ecrire dans HA. +- Le suiveur de trois panneaux (« V1 », visible dans VRM a 84 V / 0,4 A / 33 W) + n'est pas dans les globs d'entites de `ha-influxdb.yaml` : il manque au + tableau des trackers. A ajouter si le detail compte. + +--- +*Document mis a jour le : 04/09/2026 (~10:00) — tableau « Le systeme », 45/45 requetes verifiees* diff --git a/deploy/historisation/README.md b/deploy/historisation/README.md index 029dd17..09c69f1 100644 --- a/deploy/historisation/README.md +++ b/deploy/historisation/README.md @@ -226,6 +226,18 @@ Mot de passe perdu : 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. +- **Le système** — réplique locale de la vue générale VRM : réseau, charges, + batterie, borne, production, **détail par tracker**, les flux dans le temps + et l'énergie heure par heure. Existe parce que la console Victron est + hébergée dans le cloud et disparaît quand une coupure d'orage emporte + Internet. Tout y est lu sur le réseau local. + + ⚠️ Les énergies sont obtenues **en intégrant la puissance**, jamais en lisant + les compteurs kWh du GX : `gx_device_pv_energy` et les compteurs d'énergie + batterie se remettent à zéro sans le signaler, et Home Assistant enregistre + alors une variation de −373 kWh sur une heure (vérifié le 04/09). Les + compteurs `*_total_yield` des chargeurs et ceux du MultiPlus, eux, sont + monotones et sains. - **Sur le téléphone** — une seule colonne, six panneaux, et la réponse en premier. À épingler sur l'écran d'accueil de l'iPhone : `http://10.10.10.22:3001/d/evcs-telephone?kiosk` (Safari → Partager → Sur diff --git a/deploy/historisation/grafana/dashboards/systeme.json b/deploy/historisation/grafana/dashboards/systeme.json new file mode 100644 index 0000000..b30dd30 --- /dev/null +++ b/deploy/historisation/grafana/dashboards/systeme.json @@ -0,0 +1,1046 @@ +{ + "uid": "evcs-systeme", + "title": "Le systeme", + "description": "Replique locale de la vue generale VRM. La console Victron est hebergee dans le cloud : elle disparait quand une coupure d'orage emporte Internet. Tout ce qui est affiche ici est lu sur le reseau local.", + "tags": [ + "evcs" + ], + "timezone": "Europe/Paris", + "editable": true, + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { + "from": "now/d", + "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": "stat", + "title": "Reseau", + "description": "Positif : le reseau alimente la maison. Le contacteur EDF est pilote par le workflow d'hysteresis, pas depuis ici.", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"W\" and r.entity_id == \"victron_grid_power_l1\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Puissance\"})" + }, + { + "refId": "B", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"V\" and r.entity_id == \"airtria_mpii_v1_input_voltage_l1\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Tension\"})" + }, + { + "refId": "C", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"A\" and r.entity_id == \"gx_device_grid_current_l1\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Courant\"})" + } + ], + "gridPos": { + "x": 0, + "y": 0, + "w": 6, + "h": 5 + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Puissance" + }, + "properties": [ + { + "id": "unit", + "value": "watt" + }, + { + "id": "decimals", + "value": 0 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Tension" + }, + "properties": [ + { + "id": "unit", + "value": "volt" + }, + { + "id": "decimals", + "value": 0 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Courant" + }, + "properties": [ + { + "id": "unit", + "value": "amp" + }, + { + "id": "decimals", + "value": 1 + } + ] + } + ] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "orientation": "horizontal", + "textMode": "auto", + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "percentChangeColorMode": "standard", + "wideLayout": true + } + }, + { + "type": "stat", + "title": "Charges CA", + "description": "Ce que la maison consomme, voiture comprise.", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"W\" and r.entity_id == \"victron_consumption_power_l1\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Consommation\"})" + }, + { + "refId": "B", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"Hz\" and r.entity_id == \"airtria_mpii_v1_output_frequency_l1\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Frequence\"})" + } + ], + "gridPos": { + "x": 6, + "y": 0, + "w": 5, + "h": 5 + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Consommation" + }, + "properties": [ + { + "id": "unit", + "value": "watt" + }, + { + "id": "decimals", + "value": 0 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Frequence" + }, + "properties": [ + { + "id": "unit", + "value": "hertz" + }, + { + "id": "decimals", + "value": 1 + } + ] + } + ] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "orientation": "horizontal", + "textMode": "auto", + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "percentChangeColorMode": "standard", + "wideLayout": true + } + }, + { + "type": "stat", + "title": "Batterie", + "description": "Puissance positive : la batterie se charge. Sept modules actifs sur huit, un pack bloquant la charge vers 94 %.", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"%\" and r.entity_id == \"victron_battery_soc\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Charge\"})" + }, + { + "refId": "B", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"W\" and r.entity_id == \"victron_battery_power\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Puissance\"})" + }, + { + "refId": "C", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"V\" and r.entity_id == \"victron_battery_voltage\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Tension\"})" + }, + { + "refId": "D", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"A\" and r.entity_id == \"victron_battery_current\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Courant\"})" + }, + { + "refId": "E", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"°C\" and r.entity_id == \"victron_battery_temperature\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Temperature\"})" + } + ], + "gridPos": { + "x": 11, + "y": 0, + "w": 8, + "h": 5 + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Charge" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "decimals", + "value": 0 + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 25 + }, + { + "color": "yellow", + "value": 40 + }, + { + "color": "green", + "value": 60 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Puissance" + }, + "properties": [ + { + "id": "unit", + "value": "watt" + }, + { + "id": "decimals", + "value": 0 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Tension" + }, + "properties": [ + { + "id": "unit", + "value": "volt" + }, + { + "id": "decimals", + "value": 2 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Courant" + }, + "properties": [ + { + "id": "unit", + "value": "amp" + }, + { + "id": "decimals", + "value": 1 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Temperature" + }, + "properties": [ + { + "id": "unit", + "value": "celsius" + }, + { + "id": "decimals", + "value": 0 + } + ] + } + ] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "orientation": "horizontal", + "textMode": "auto", + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "percentChangeColorMode": "standard", + "wideLayout": true + } + }, + { + "type": "stat", + "title": "Chargeur VE", + "description": "Consigne = ce que le controleur demande a la borne, reecrit toutes les 30 s. Courant = ce que la voiture prend reellement.", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"W\" and r.entity_id == \"victron_evcs_ac_power\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Puissance\"})" + }, + { + "refId": "B", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"A\" and r.entity_id == \"victron_evcs_current\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Courant\"})" + }, + { + "refId": "C", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"A\" and r.entity_id == \"victron_evcs_set_current\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Consigne\"})" + } + ], + "gridPos": { + "x": 19, + "y": 0, + "w": 5, + "h": 5 + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Puissance" + }, + "properties": [ + { + "id": "unit", + "value": "watt" + }, + { + "id": "decimals", + "value": 0 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Courant" + }, + "properties": [ + { + "id": "unit", + "value": "amp" + }, + { + "id": "decimals", + "value": 1 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Consigne" + }, + "properties": [ + { + "id": "unit", + "value": "amp" + }, + { + "id": "decimals", + "value": 0 + } + ] + } + ] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "orientation": "horizontal", + "textMode": "auto", + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "percentChangeColorMode": "standard", + "wideLayout": true + } + }, + { + "type": "stat", + "title": "Chargeur PV", + "description": "Rappel qui vaut pour toute lecture de cette valeur : sans injection, la production MESUREE vaut min(ressource, consommation + absorption batterie). Basse ne veut pas dire mauvais temps.", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"W\" and r.entity_id == \"victron_pv_power_system\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Production\"})" + }, + { + "refId": "B", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._measurement == \"W\" and r.entity_id == \"victron_pv_power_10m_avg\" and r._field == \"value\")\n |> last()\n |> keep(columns: [\"_time\", \"_value\"])\n |> rename(columns: {_value: \"Moyenne 10 min\"})" + } + ], + "gridPos": { + "x": 0, + "y": 5, + "w": 6, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Production" + }, + "properties": [ + { + "id": "unit", + "value": "watt" + }, + { + "id": "decimals", + "value": 0 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Moyenne 10 min" + }, + "properties": [ + { + "id": "unit", + "value": "watt" + }, + { + "id": "decimals", + "value": 0 + } + ] + } + ] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "orientation": "horizontal", + "textMode": "auto", + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "percentChangeColorMode": "standard", + "wideLayout": true + } + }, + { + "type": "table", + "title": "Detail par tracker", + "description": "Les 52 panneaux en quatre plans : sud-est 40 degres, trois chaines sud 35 degres, ouest 45 degres. Le suiveur de trois panneaux ne publie pas de mode de fonctionnement et n'apparait donc pas ici.", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "evcs-influx" + }, + "query": "import \"strings\"\n\nbase = from(bucket: \"evcs\")\n |> range(start: -7d)\n |> filter(fn: (r) => r._field == \"value\")\n |> filter(fn: (r) => r._measurement == \"V\" or r._measurement == \"A\" or r._measurement == \"W\")\n |> last()\n\nrs = base\n |> filter(fn: (r) => strings.containsStr(v: r.entity_id, substr: \"pv_tracker_solar_\"))\n |> map(fn: (r) => ({ r with tracker:\n if strings.containsStr(v: r.entity_id, substr: \"solar_1\") then \"Sud 1 (10 pan.)\"\n else if strings.containsStr(v: r.entity_id, substr: \"solar_2\") then \"Sud 2 (10 pan.)\"\n else if strings.containsStr(v: r.entity_id, substr: \"solar_3\") then \"Sud 3 (10 pan.)\"\n else \"Ouest (9 pan.)\" }))\n\nse = base\n |> filter(fn: (r) => r.entity_id == \"airtria_mppt1_pv_bus_voltage\"\n or r.entity_id == \"airtria_mppt1_pv_current\"\n or r.entity_id == \"airtria_mppt1_pv_yield_power\")\n |> map(fn: (r) => ({ r with tracker: \"Sud-est (10 pan.)\" }))\n\nunion(tables: [rs, se])\n |> keep(columns: [\"tracker\", \"_measurement\", \"_value\"])\n |> pivot(rowKey: [\"tracker\"], columnKey: [\"_measurement\"], valueColumn: \"_value\")\n |> sort(columns: [\"tracker\"])" + } + ], + "gridPos": { + "x": 6, + "y": 5, + "w": 18, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + }, + "filterable": true + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "tracker" + }, + "properties": [ + { + "id": "displayName", + "value": "Tracker" + }, + { + "id": "custom.width", + "value": 200 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "V" + }, + "properties": [ + { + "id": "displayName", + "value": "Tension" + }, + { + "id": "unit", + "value": "volt" + }, + { + "id": "decimals", + "value": 0 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "A" + }, + "properties": [ + { + "id": "displayName", + "value": "Courant" + }, + { + "id": "unit", + "value": "amp" + }, + { + "id": "decimals", + "value": 1 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "W" + }, + "properties": [ + { + "id": "displayName", + "value": "Puissance" + }, + { + "id": "unit", + "value": "watt" + }, + { + "id": "decimals", + "value": 0 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "gauge", + "mode": "gradient" + } + }, + { + "id": "max", + "value": 3100 + } + ] + } + ] + }, + "options": { + "showHeader": true, + "cellHeight": "sm", + "footer": { + "show": false + } + }, + "transformations": [ + { + "id": "organize", + "options": { + "indexByName": { + "tracker": 0, + "V": 1, + "A": 2, + "W": 3 + } + } + } + ] + }, + { + "type": "timeseries", + "title": "Les flux", + "description": "Batterie positive : elle se charge. Reseau positif : il alimente. Aux instants ou tout est a zero sauf la maison, c'est la batterie qui porte tout.", + "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": "WITH par_minute AS (\n SELECT date_trunc('minute', ts) AS t,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_pv_power_system') AS Solaire,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_grid_power_l1') AS Reseau,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_battery_power') AS Batterie,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS Maison,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power') AS Voiture\n FROM mesure WHERE $__timeFilter(ts) GROUP BY 1\n),\npaliers AS (\n SELECT t, Solaire, Reseau, Batterie, Maison, Voiture,\n count(Solaire) OVER (ORDER BY t) AS g_Solaire,\n count(Reseau) OVER (ORDER BY t) AS g_Reseau,\n count(Batterie) OVER (ORDER BY t) AS g_Batterie,\n count(Maison) OVER (ORDER BY t) AS g_Maison,\n count(Voiture) OVER (ORDER BY t) AS g_Voiture\n FROM par_minute\n),\nreport AS (\n SELECT t,\n first_value(Solaire) OVER (PARTITION BY g_Solaire ORDER BY t) AS Solaire,\n first_value(Reseau) OVER (PARTITION BY g_Reseau ORDER BY t) AS Reseau,\n first_value(Batterie) OVER (PARTITION BY g_Batterie ORDER BY t) AS Batterie,\n first_value(Maison) OVER (PARTITION BY g_Maison ORDER BY t) AS Maison,\n first_value(Voiture) OVER (PARTITION BY g_Voiture ORDER BY t) AS Voiture\n FROM paliers\n)\nSELECT t AS time,\n Solaire AS \"Solaire\",\n Reseau AS \"Reseau\",\n Batterie AS \"Batterie\",\n Maison AS \"Maison\",\n Voiture AS \"Voiture\"\nFROM report ORDER BY t" + } + ], + "gridPos": { + "x": 0, + "y": 13, + "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": "Solaire" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "yellow" + } + } + ] + }, + { + "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": "blue" + } + } + ] + }, + { + "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": "green" + } + } + ] + } + ] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "type": "barchart", + "title": "Energie heure par heure", + "description": "Obtenue en integrant la puissance, PAS en lisant les compteurs kWh du GX : ceux-ci se remettent a zero sans le signaler (verifie le 04/09, -373 kWh enregistres sur une heure cote Home Assistant).", + "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 axe AS (\n SELECT generate_series(date_trunc('minute', $__timeFrom()::timestamptz),\n date_trunc('minute', $__timeTo()::timestamptz),\n interval '1 minute') AS t\n),\nreleve AS (\n SELECT date_trunc('minute', ts) AS t,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_pv_power_system') AS Solaire,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_grid_power_l1') AS Reseau,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_consumption_power_l1') AS Maison,\n avg(valeur) FILTER (WHERE cle = 'sensor.victron_evcs_ac_power') AS Voiture\n FROM mesure WHERE $__timeFilter(ts) GROUP BY 1\n),\npar_minute AS (\n SELECT axe.t, releve.Solaire, releve.Reseau, releve.Maison, releve.Voiture\n FROM axe LEFT JOIN releve USING (t)\n),\npaliers AS (\n SELECT t, Solaire, Reseau, Maison, Voiture,\n count(Solaire) OVER (ORDER BY t) AS g_Solaire,\n count(Reseau) OVER (ORDER BY t) AS g_Reseau,\n count(Maison) OVER (ORDER BY t) AS g_Maison,\n count(Voiture) OVER (ORDER BY t) AS g_Voiture\n FROM par_minute\n),\nreport AS (\n SELECT t,\n first_value(Solaire) OVER (PARTITION BY g_Solaire ORDER BY t) AS Solaire,\n first_value(Reseau) OVER (PARTITION BY g_Reseau ORDER BY t) AS Reseau,\n first_value(Maison) OVER (PARTITION BY g_Maison ORDER BY t) AS Maison,\n first_value(Voiture) OVER (PARTITION BY g_Voiture ORDER BY t) AS Voiture\n FROM paliers\n)\nSELECT to_char(t, 'HH24') || ' h' AS \"Heure\",\n round((avg(Solaire) * count(Solaire) / 60.0 / 1000.0)::numeric, 2) AS \"Solaire\",\n round((avg(Reseau) * count(Reseau) / 60.0 / 1000.0)::numeric, 2) AS \"Reseau\",\n round((avg(Maison) * count(Maison) / 60.0 / 1000.0)::numeric, 2) AS \"Maison\",\n round((avg(Voiture) * count(Voiture) / 60.0 / 1000.0)::numeric, 2) AS \"Voiture\"\nFROM report\nGROUP BY date_trunc('hour', t), to_char(t, 'HH24')\nHAVING count(Solaire) > 0 OR count(Reseau) > 0 OR count(Maison) > 0 OR count(Voiture) > 0\nORDER BY date_trunc('hour', t)" + } + ], + "gridPos": { + "x": 0, + "y": 22, + "w": 24, + "h": 9 + }, + "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" + } + } + } + ] +} diff --git a/tools/gen-grafana-dashboards.py b/tools/gen-grafana-dashboards.py index 253541f..2273002 100644 --- a/tools/gen-grafana-dashboards.py +++ b/tools/gen-grafana-dashboards.py @@ -597,9 +597,251 @@ telephone = dashboard("evcs-telephone", "Sur le telephone", ( overrides=[sur("Quand", [{"id": "custom.width", "value": 110}])]), ]) + +# ===================================================================== +# 5. LE SYSTEME — replique locale de la vue generale VRM +# ===================================================================== +# Raison d'etre : la console VRM est hebergee dans le cloud Victron. Elle +# disparait exactement quand on en a le plus besoin — une coupure d'orage sur +# ce site emporte souvent Internet avec elle. Tout ce qui suit est lu sur le +# reseau local, sans exception. +# +# Choix de methode : les energies horaires sont obtenues en INTEGRANT LA +# PUISSANCE, et non en lisant les compteurs kWh de Victron. Verifie le +# 04/09 : `gx_device_pv_energy` et les compteurs d'energie batterie du GX se +# remettent a zero sans le signaler, et Home Assistant enregistre alors une +# variation de -373 kWh sur une heure. Integrer la puissance ignore +# completement ce probleme. + +def flux_dernier(refid, mesure, entite, libelle, champ="value"): + """Derniere valeur connue d'une entite, renommee pour l'affichage.""" + # Fenetre de 7 jours et non de quelques minutes : l'integration InfluxDB + # de Home Assistant n'ecrit QUE sur changement d'etat. Une grandeur stable + # — la borne a 0 A, voiture debranchee — n'a donc aucun point recent, et + # une fenetre courte affiche une case vide la ou la valeur est simplement + # constante. `last()` reste peu couteux sur une fenetre large. + return flux(refid, + f'from(bucket: "evcs")\n' + f' |> range(start: -7d)\n' + f' |> filter(fn: (r) => r._measurement == "{mesure}"' + f' and r.entity_id == "{entite}" and r._field == "{champ}")\n' + f' |> last()\n' + f' |> keep(columns: ["_time", "_value"])\n' + f' |> rename(columns: {{_value: "{libelle}"}})') + +# Energie horaire par integration ponderee par la duree, plafonnee a 300 s +# pour qu'un arret du service n'extrapole pas une valeur perimee. +ENERGIE_CLES = [ + ('sensor.victron_pv_power_system', 'Solaire'), + ('sensor.victron_grid_power_l1', 'Reseau'), + ('sensor.victron_consumption_power_l1', 'Maison'), + ('sensor.victron_evcs_ac_power', 'Voiture'), +] + +# Energie horaire = moyenne de la puissance sur l'heure, en kWh. +# +# Deux pieges, tous deux rencontres le 04/09 avant d'arriver a cette version : +# +# 1. Somme des echantillons ponderee par leur intervalle : laisse des TROUS. +# « Solaire » n'existait qu'a 00 h, 07 h, 08 h et 09 h, parce qu'une +# grandeur qui ne change pas n'est jamais reecrite — la production nulle +# de la nuit ne produit qu'UNE ligne, et les heures sans ligne sortaient +# vides au lieu de sortir zero. +# +# 2. Moyenne des minutes PRESENTES : sur-compte. L'heure 01 annoncait +# 5,13 kWh de reseau alors que la fenetre heures creuses ne s'ouvre qu'a +# 01:24 — la moyenne portait sur 36 minutes et les comptait comme 60. +# +# D'ou l'axe des minutes COMPLET par `generate_series`, la valeur reportee +# depuis le dernier point connu, puis la moyenne. Chaque heure pese alors ses +# soixante minutes, qu'il y ait eu une mesure ou non. +ENERGIE_SQL = """WITH axe AS ( + SELECT generate_series(date_trunc('minute', $__timeFrom()::timestamptz), + date_trunc('minute', $__timeTo()::timestamptz), + interval '1 minute') AS t +), +releve AS ( + SELECT date_trunc('minute', ts) AS t, +""" + ",\n".join( + f" avg(valeur) FILTER (WHERE cle = '{c}') AS {n}" for c, n in ENERGIE_CLES) + """ + FROM mesure WHERE $__timeFilter(ts) GROUP BY 1 +), +par_minute AS ( + SELECT axe.t, """ + ", ".join(f"releve.{n}" for _, n in ENERGIE_CLES) + """ + FROM axe LEFT JOIN releve USING (t) +), +paliers AS ( + SELECT t, """ + ", ".join(n for _, n in ENERGIE_CLES) + ",\n" + ",\n".join( + f" count({n}) OVER (ORDER BY t) AS g_{n}" for _, n in ENERGIE_CLES) + """ + FROM par_minute +), +report AS ( + SELECT t, +""" + ",\n".join( + f" first_value({n}) OVER (PARTITION BY g_{n} ORDER BY t) AS {n}" + for _, n in ENERGIE_CLES) + """ + FROM paliers +) +SELECT to_char(t, 'HH24') || ' h' AS "Heure", +""" + ",\n".join( + # Energie REELLEMENT accumulee : puissance moyenne x duree observee. + # Pas la moyenne seule, qui comptait 22 minutes comme 60 et annoncait + # 5,13 kWh de reseau a 01 h le 04/09 — le service n'avait demarre qu'a + # 01:37. Pas non plus un rejet des heures incompletes, qui laissait + # toujours vide l'heure EN COURS, celle qu'on regarde le plus. + # Contrepartie assumee : une heure passee amputee par un arret du service + # sous-estime — mais un tel arret est journalise et apparait en marqueur + # sur le graphique. + f' round((avg({n}) * count({n}) / 60.0 / 1000.0)::numeric, 2) AS "{n}"' + for _, n in ENERGIE_CLES) + """ +FROM report +GROUP BY date_trunc('hour', t), to_char(t, 'HH24') +HAVING """ + " OR ".join(f"count({n}) > 0" for _, n in ENERGIE_CLES) + """ +ORDER BY date_trunc('hour', t)""" + +# Detail par tracker. Les quatre entrees du RS 450/200 et le MPPT1 ne suivent +# pas la meme convention de nommage : deux branches, reunies puis pivotees. +TRACKERS_FLUX = '''import "strings" + +base = from(bucket: "evcs") + |> range(start: -7d) + |> filter(fn: (r) => r._field == "value") + |> filter(fn: (r) => r._measurement == "V" or r._measurement == "A" or r._measurement == "W") + |> last() + +rs = base + |> filter(fn: (r) => strings.containsStr(v: r.entity_id, substr: "pv_tracker_solar_")) + |> map(fn: (r) => ({ r with tracker: + if strings.containsStr(v: r.entity_id, substr: "solar_1") then "Sud 1 (10 pan.)" + else if strings.containsStr(v: r.entity_id, substr: "solar_2") then "Sud 2 (10 pan.)" + else if strings.containsStr(v: r.entity_id, substr: "solar_3") then "Sud 3 (10 pan.)" + else "Ouest (9 pan.)" })) + +se = base + |> filter(fn: (r) => r.entity_id == "airtria_mppt1_pv_bus_voltage" + or r.entity_id == "airtria_mppt1_pv_current" + or r.entity_id == "airtria_mppt1_pv_yield_power") + |> map(fn: (r) => ({ r with tracker: "Sud-est (10 pan.)" })) + +union(tables: [rs, se]) + |> keep(columns: ["tracker", "_measurement", "_value"]) + |> pivot(rowKey: ["tracker"], columnKey: ["_measurement"], valueColumn: "_value") + |> sort(columns: ["tracker"])''' + +def col(nom, unite, largeur=None, dec=0): + props = [{"id": "unit", "value": unite}, {"id": "decimals", "value": dec}] + if largeur: + props.append({"id": "custom.width", "value": largeur}) + return sur(nom, props) + +systeme = dashboard("evcs-systeme", "Le systeme", ( + "Replique locale de la vue generale VRM. La console Victron est hebergee " + "dans le cloud : elle disparait quand une coupure d'orage emporte " + "Internet. Tout ce qui est affiche ici est lu sur le reseau local."), + frm="now/d", refresh="30s", panels=[ + + stat("Reseau", [ + flux_dernier("A", "W", "victron_grid_power_l1", "Puissance"), + flux_dernier("B", "V", "airtria_mpii_v1_input_voltage_l1", "Tension"), + flux_dernier("C", "A", "gx_device_grid_current_l1", "Courant")], + IN, 0, 0, 6, 5, + "Positif : le reseau alimente la maison. Le contacteur EDF est pilote par " + "le workflow d'hysteresis, pas depuis ici.", + overrides=[col("Puissance", "watt"), col("Tension", "volt"), col("Courant", "amp", dec=1)]), + + stat("Charges CA", [ + flux_dernier("A", "W", "victron_consumption_power_l1", "Consommation"), + flux_dernier("B", "Hz", "airtria_mpii_v1_output_frequency_l1", "Frequence")], + IN, 6, 0, 5, 5, + "Ce que la maison consomme, voiture comprise.", + overrides=[col("Consommation", "watt"), col("Frequence", "hertz", dec=1)]), + + stat("Batterie", [ + flux_dernier("A", "%", "victron_battery_soc", "Charge"), + flux_dernier("B", "W", "victron_battery_power", "Puissance"), + flux_dernier("C", "V", "victron_battery_voltage", "Tension"), + flux_dernier("D", "A", "victron_battery_current", "Courant"), + flux_dernier("E", "°C", "victron_battery_temperature", "Temperature")], + IN, 11, 0, 8, 5, + "Puissance positive : la batterie se charge. Sept modules actifs sur huit, " + "un pack bloquant la charge vers 94 %.", + overrides=[ + sur("Charge", [{"id": "unit", "value": "percent"}, {"id": "decimals", "value": 0}, + {"id": "thresholds", "value": seuils( + ("red", None), ("orange", 25), ("yellow", 40), ("green", 60))}]), + col("Puissance", "watt"), col("Tension", "volt", dec=2), + col("Courant", "amp", dec=1), col("Temperature", "celsius")]), + + stat("Chargeur VE", [ + flux_dernier("A", "W", "victron_evcs_ac_power", "Puissance"), + flux_dernier("B", "A", "victron_evcs_current", "Courant"), + flux_dernier("C", "A", "victron_evcs_set_current", "Consigne")], + IN, 19, 0, 5, 5, + "Consigne = ce que le controleur demande a la borne, reecrit toutes les " + "30 s. Courant = ce que la voiture prend reellement.", + overrides=[col("Puissance", "watt"), col("Courant", "amp", dec=1), + col("Consigne", "amp", dec=0)]), + + stat("Chargeur PV", [ + flux_dernier("A", "W", "victron_pv_power_system", "Production"), + flux_dernier("B", "W", "victron_pv_power_10m_avg", "Moyenne 10 min")], + IN, 0, 5, 6, 8, + "Rappel qui vaut pour toute lecture de cette valeur : sans injection, la " + "production MESUREE vaut min(ressource, consommation + absorption " + "batterie). Basse ne veut pas dire mauvais temps.", + overrides=[col("Production", "watt"), col("Moyenne 10 min", "watt")]), + + dict(table("Detail par tracker", [flux("A", TRACKERS_FLUX)], + IN, 6, 5, 18, 8, + "Les 52 panneaux en quatre plans : sud-est 40 degres, trois chaines sud " + "35 degres, ouest 45 degres. Le suiveur de trois panneaux ne publie pas " + "de mode de fonctionnement et n'apparait donc pas ici.", + overrides=[sur("tracker", [{"id": "displayName", "value": "Tracker"}, + {"id": "custom.width", "value": 200}]), + sur("V", [{"id": "displayName", "value": "Tension"}, + {"id": "unit", "value": "volt"}, {"id": "decimals", "value": 0}]), + sur("A", [{"id": "displayName", "value": "Courant"}, + {"id": "unit", "value": "amp"}, {"id": "decimals", "value": 1}]), + sur("W", [{"id": "displayName", "value": "Puissance"}, + {"id": "unit", "value": "watt"}, {"id": "decimals", "value": 0}, + {"id": "custom.cellOptions", + "value": {"type": "gauge", "mode": "gradient"}}, + {"id": "max", "value": 3100}])]), + # `pivot` rend les colonnes par ordre alphabetique (A, V, W). On retablit + # l'ordre de lecture : tension, courant, puissance. + transformations=[{"id": "organize", "options": { + "indexByName": {"tracker": 0, "V": 1, "A": 2, "W": 3}}}]), + + ts("Les flux", [ + sql_grandeurs("A", [ + ('sensor.victron_pv_power_system', 'Solaire'), + ('sensor.victron_grid_power_l1', 'Reseau'), + ('sensor.victron_battery_power', 'Batterie'), + ('sensor.victron_consumption_power_l1', 'Maison'), + ('sensor.victron_evcs_ac_power', 'Voiture')])], + PG, 0, 13, 24, 9, "watt", + "Batterie positive : elle se charge. Reseau positif : il alimente. " + "Aux instants ou tout est a zero sauf la maison, c'est la batterie qui " + "porte tout.", + extra={"overrides": [ + sur("Solaire", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "yellow"}}]), + sur("Reseau", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}]), + sur("Batterie", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "blue"}}]), + sur("Maison", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "text"}}, + {"id": "custom.fillOpacity", "value": 0}]), + sur("Voiture", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}])]}), + + barchart("Energie heure par heure", [sql("A", ENERGIE_SQL, fmt="table")], + PG, 0, 22, 24, 9, "kwatth", + "Obtenue en integrant la puissance, PAS en lisant les compteurs kWh du " + "GX : ceux-ci se remettent a zero sans le signaler (verifie le 04/09, " + "-373 kWh enregistres sur une heure cote Home Assistant)."), +]) + base = pathlib.Path('deploy/historisation/grafana/dashboards') for nom, d in [('nuit.json', nuit), ('solaire.json', solaire), ('prevision.json', prevision), - ('telephone.json', telephone)]: + ('telephone.json', telephone), + ('systeme.json', systeme)]: (base / nom).write_text(json.dumps(d, indent=2, ensure_ascii=False) + '\n') print(f"{nom:16s} {len(d['panels'])} panneaux")