diff --git a/deploy/historisation/grafana/dashboards/vehicules.json b/deploy/historisation/grafana/dashboards/vehicules.json new file mode 100644 index 0000000..3d21c38 --- /dev/null +++ b/deploy/historisation/grafana/dashboards/vehicules.json @@ -0,0 +1,389 @@ +{ + "templating": { + "list": [] + }, + "uid": "evcs-vehicules", + "title": "Les vehicules", + "description": "Ce que chaque voiture encaisse, session par session et sur la periode choisie. L'etiquette vient d'un selecteur manuel : elle peut etre fausse, et rien ne permet de la verifier — les deux vehicules sont electriquement indiscernables et leur routine s'inverse le week-end.", + "tags": [ + "evcs" + ], + "timezone": "Europe/Paris", + "editable": true, + "schemaVersion": 39, + "version": 1, + "refresh": "5m", + "time": { + "from": "now-7d", + "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": "Sur la periode", + "description": "« Etiquette douteuse » compte les sessions ou le selecteur a change en cours de route, ou n'etait pas renseigne. C'est la mesure honnete de l'incertitude : elle ne peut pas etre levee autrement qu'en positionnant le selecteur au branchement.", + "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 round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'VW'), 0)::numeric, 1) AS \"VW\",\n round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'JEEP'), 0)::numeric, 1) AS \"JEEP\",\n count(*) AS \"Sessions\",\n count(*) FILTER (WHERE NOT vehicule_sur OR vehicule IS NULL) AS \"Etiquette douteuse\"\nFROM session_charge WHERE $__timeFilter(debut)" + } + ], + "gridPos": { + "x": 0, + "y": 0, + "w": 24, + "h": 5 + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "VW" + }, + "properties": [ + { + "id": "unit", + "value": "kwatth" + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "blue" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "JEEP" + }, + "properties": [ + { + "id": "unit", + "value": "kwatth" + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "green" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Etiquette douteuse" + }, + "properties": [ + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + } + ] + } + } + ] + } + ] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "orientation": "horizontal", + "textMode": "auto", + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "percentChangeColorMode": "standard", + "noValue": "—", + "text": { + "valueSize": 34, + "titleSize": 12 + }, + "wideLayout": true + } + }, + { + "type": "barchart", + "title": "Energie par periode et par vehicule", + "description": "Le pas suit la plage choisie : l'heure jusqu'a 4 jours, le jour jusqu'a 70, le mois au-dela. « Non etiquete » est ce qui a ete charge sans que le selecteur soit renseigne — a interpreter, pas a ignorer.", + "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 bornes AS (\n SELECT $__timeFrom()::timestamptz AS d, $__timeTo()::timestamptz AS f\n),\npas AS (\n SELECT CASE WHEN f - d > interval '70 days' THEN 'month'\n WHEN f - d > interval '4 days' THEN 'day'\n ELSE 'hour' END AS unite,\n CASE WHEN f - d > interval '70 days' THEN 'MM/YYYY'\n WHEN f - d > interval '4 days' THEN 'DD/MM'\n ELSE 'DD/MM HH24' END AS forme\n FROM bornes\n)\nSELECT to_char(date_trunc(pas.unite, debut), pas.forme) AS \"Periode\",\n round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'VW'), 0)::numeric, 2) AS \"VW\",\n round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'JEEP'), 0)::numeric, 2) AS \"JEEP\",\n round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule IS NULL), 0)::numeric, 2) AS \"Non etiquete\"\nFROM session_charge, pas\nWHERE $__timeFilter(debut)\nGROUP BY date_trunc(pas.unite, debut), pas.forme\nORDER BY date_trunc(pas.unite, debut)" + } + ], + "gridPos": { + "x": 0, + "y": 5, + "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" + } + } + }, + { + "type": "table", + "title": "Les sessions", + "description": "« Debranche » confirme le vehicule apres coup : les deux partent a des heures differentes. « dont reseau » se fonde sur le mode source declare par le controleur — son intention — et non sur la puissance reseau instantanee, qui peut alimenter la maison pendant que le soleil alimente la voiture.", + "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 to_char(debut, 'Dy DD/MM HH24:MI') AS \"Debut\",\n coalesce(vehicule, '?') AS \"Vehicule\",\n CASE WHEN vehicule_sur THEN '' ELSE 'selecteur change' END AS \"Doute\",\n round((extract(epoch FROM fin - debut) / 60)::numeric) AS \"Minutes\",\n round(energie_kwh::numeric, 2) AS \"kWh\",\n round(energie_reseau_kwh::numeric, 2) AS \"dont reseau\",\n round(p_max_w::numeric) AS \"P max\",\n consigne_max_a AS \"Consigne max\",\n source_mode AS \"Source au depart\",\n to_char(debranche_a, 'Dy HH24:MI') AS \"Debranche\"\nFROM session_charge WHERE $__timeFilter(debut) ORDER BY debut DESC" + } + ], + "gridPos": { + "x": 0, + "y": 14, + "w": 24, + "h": 11 + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + }, + "filterable": true + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Vehicule" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + }, + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "VW": { + "color": "blue", + "index": 0 + }, + "JEEP": { + "color": "green", + "index": 1 + }, + "?": { + "color": "orange", + "index": 2 + } + } + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Doute" + }, + "properties": [ + { + "id": "custom.width", + "value": 150 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "kWh" + }, + "properties": [ + { + "id": "unit", + "value": "kwatth" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "dont reseau" + }, + "properties": [ + { + "id": "unit", + "value": "kwatth" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "P max" + }, + "properties": [ + { + "id": "unit", + "value": "watt" + } + ] + } + ] + }, + "options": { + "showHeader": true, + "cellHeight": "sm", + "footer": { + "show": false + } + } + } + ] +} diff --git a/deploy/historisation/schema.sql b/deploy/historisation/schema.sql index c881947..27b3b8b 100644 --- a/deploy/historisation/schema.sql +++ b/deploy/historisation/schema.sql @@ -140,6 +140,10 @@ CREATE TABLE session_charge ( vehicule text, -- valeur du selecteur PENDANT la session vehicule_sur boolean NOT NULL DEFAULT true, -- le selecteur n'a pas change energie_kwh double precision NOT NULL DEFAULT 0, + -- Part venue du reseau : on se fie au `source_mode` du controleur, son + -- INTENTION, et non a la puissance reseau instantanee — laquelle peut + -- alimenter la maison pendant que le soleil alimente la voiture. + energie_reseau_kwh double precision NOT NULL DEFAULT 0, p_max_w double precision, courant_max_a double precision, consigne_max_a double precision, diff --git a/tools/gen-grafana-dashboards.py b/tools/gen-grafana-dashboards.py index 8a20c88..d23fe05 100644 --- a/tools/gen-grafana-dashboards.py +++ b/tools/gen-grafana-dashboards.py @@ -950,10 +950,119 @@ systeme = dashboard("evcs-systeme", "Le systeme", ( transformations=FILTRE_VUE), ]) + +# ===================================================================== +# 6. LES VEHICULES — ce que chaque voiture encaisse, et sur quelle periode +# ===================================================================== +# ⚠️ LIMITE STRUCTURELLE, a garder en tete a chaque lecture de ce tableau. +# L'etiquette du vehicule vient d'un SELECTEUR MANUEL. Elle est fiable quand +# le proprietaire l'a positionnee au branchement, et fausse s'il l'a oubliee ou +# si un redemarrage de Home Assistant l'a annulee (constate le 04/09 a 19:44). +# +# Il n'existe aucun moyen de la verifier : +# - electriquement, les deux voitures sont indiscernables. Mesure du 05/09 a +# consigne identique de 20 A : la VW accepte 3 680 W, la JEEP 3 655 W, +# soit 0,1 A d'ecart. Meme chargeur embarque de 3,7 kW, meme plateau. +# - horairement, la routine s'inverse le week-end : en semaine la JEEP charge +# le midi et la VW la nuit, mais le samedi la JEEP charge la nuit et la VW +# en journee. Une regle horaire serait fausse un week-end sur deux. +# +# D'ou la colonne « Doute » du tableau et le compteur d'etiquettes douteuses : +# on affiche l'incertitude au lieu de la masquer. + +SESSIONS_PERIODE = """WITH bornes AS ( + SELECT $__timeFrom()::timestamptz AS d, $__timeTo()::timestamptz AS f +), +pas AS ( + SELECT CASE WHEN f - d > interval '70 days' THEN 'month' + WHEN f - d > interval '4 days' THEN 'day' + ELSE 'hour' END AS unite, + CASE WHEN f - d > interval '70 days' THEN 'MM/YYYY' + WHEN f - d > interval '4 days' THEN 'DD/MM' + ELSE 'DD/MM HH24' END AS forme + FROM bornes +) +SELECT to_char(date_trunc(pas.unite, debut), pas.forme) AS "Periode", + round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'VW'), 0)::numeric, 2) AS "VW", + round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'JEEP'), 0)::numeric, 2) AS "JEEP", + round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule IS NULL), 0)::numeric, 2) AS "Non etiquete" +FROM session_charge, pas +WHERE $__timeFilter(debut) +GROUP BY date_trunc(pas.unite, debut), pas.forme +ORDER BY date_trunc(pas.unite, debut)""" + +vehicules = dashboard("evcs-vehicules", "Les vehicules", ( + "Ce que chaque voiture encaisse, session par session et sur la periode " + "choisie. L'etiquette vient d'un selecteur manuel : elle peut etre fausse, " + "et rien ne permet de la verifier — les deux vehicules sont " + "electriquement indiscernables et leur routine s'inverse le week-end."), + frm="now-7d", refresh="5m", panels=[ + + stat("Sur la periode", [ + sql("A", + "SELECT round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'VW'), 0)::numeric, 1) AS \"VW\",\n" + " round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'JEEP'), 0)::numeric, 1) AS \"JEEP\",\n" + " count(*) AS \"Sessions\",\n" + " count(*) FILTER (WHERE NOT vehicule_sur OR vehicule IS NULL) AS \"Etiquette douteuse\"\n" + "FROM session_charge WHERE $__timeFilter(debut)", fmt="table")], + PG, 0, 0, 24, 5, + "« Etiquette douteuse » compte les sessions ou le selecteur a change en " + "cours de route, ou n'etait pas renseigne. C'est la mesure honnete de " + "l'incertitude : elle ne peut pas etre levee autrement qu'en positionnant " + "le selecteur au branchement.", + taille={"valueSize": 34, "titleSize": 12}, + overrides=[ + sur("VW", [{"id": "unit", "value": "kwatth"}, + {"id": "color", "value": {"mode": "fixed", "fixedColor": "blue"}}]), + sur("JEEP", [{"id": "unit", "value": "kwatth"}, + {"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}]), + sur("Etiquette douteuse", [{"id": "thresholds", "value": seuils( + ("green", None), ("orange", 1))}])]), + + barchart("Energie par periode et par vehicule", + [sql("A", SESSIONS_PERIODE, fmt="table")], + PG, 0, 5, 24, 9, "kwatth", + "Le pas suit la plage choisie : l'heure jusqu'a 4 jours, le jour jusqu'a " + "70, le mois au-dela. « Non etiquete » est ce qui a ete charge sans que le " + "selecteur soit renseigne — a interpreter, pas a ignorer."), + + table("Les sessions", [ + sql("A", + "SELECT to_char(debut, 'Dy DD/MM HH24:MI') AS \"Debut\",\n" + " coalesce(vehicule, '?') AS \"Vehicule\",\n" + " CASE WHEN vehicule_sur THEN '' ELSE 'selecteur change' END AS \"Doute\",\n" + " round((extract(epoch FROM fin - debut) / 60)::numeric) AS \"Minutes\",\n" + " round(energie_kwh::numeric, 2) AS \"kWh\",\n" + " round(energie_reseau_kwh::numeric, 2) AS \"dont reseau\",\n" + " round(p_max_w::numeric) AS \"P max\",\n" + " consigne_max_a AS \"Consigne max\",\n" + " source_mode AS \"Source au depart\",\n" + " to_char(debranche_a, 'Dy HH24:MI') AS \"Debranche\"\n" + "FROM session_charge WHERE $__timeFilter(debut) ORDER BY debut DESC", fmt="table")], + PG, 0, 14, 24, 11, + "« Debranche » confirme le vehicule apres coup : les deux partent a des " + "heures differentes. « dont reseau » se fonde sur le mode source declare " + "par le controleur — son intention — et non sur la puissance reseau " + "instantanee, qui peut alimenter la maison pendant que le soleil alimente " + "la voiture.", + overrides=[ + sur("Vehicule", [{"id": "custom.width", "value": 100}, + {"id": "custom.cellOptions", "value": {"type": "color-text"}}, + {"id": "mappings", "value": [{"type": "value", "options": { + "VW": {"color": "blue", "index": 0}, + "JEEP": {"color": "green", "index": 1}, + "?": {"color": "orange", "index": 2}}}]}]), + sur("Doute", [{"id": "custom.width", "value": 150}]), + sur("kWh", [{"id": "unit", "value": "kwatth"}]), + sur("dont reseau", [{"id": "unit", "value": "kwatth"}]), + sur("P max", [{"id": "unit", "value": "watt"}])]), +]) + base = pathlib.Path('deploy/historisation/grafana/dashboards') for nom, d in [('nuit.json', nuit), ('solaire.json', solaire), ('prevision.json', prevision), ('telephone.json', telephone), - ('systeme.json', systeme)]: + ('systeme.json', systeme), + ('vehicules.json', vehicules)]: (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/historise-evcs.mjs b/tools/historise-evcs.mjs index 66d5565..4fd261c 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -349,6 +349,12 @@ async function migrer(client) { debranche_a timestamptz, brut jsonb )`); + // Part venue du reseau. On se fie au `source_mode` du controleur — son + // INTENTION, sans ambiguite — et non a la puissance reseau instantanee, qui + // peut alimenter la maison pendant que le soleil alimente la voiture. + await client.query( + 'ALTER TABLE session_charge ADD COLUMN IF NOT EXISTS energie_reseau_kwh ' + + 'double precision NOT NULL DEFAULT 0'); await client.query( 'CREATE INDEX IF NOT EXISTS session_charge_debut_idx ON session_charge (debut DESC)'); // Le role de lecture doit voir la nouvelle table. S'il n'existe pas encore, @@ -413,6 +419,7 @@ async function ecrireSession(sql, client, st) { `UPDATE session_charge SET fin = $2, energie_kwh = energie_kwh + $3 * $4 / 3600000.0, + energie_reseau_kwh = energie_reseau_kwh + $8 * $3 * $4 / 3600000.0, p_max_w = GREATEST(coalesce(p_max_w, 0), $3), courant_max_a = GREATEST(coalesce(courant_max_a, 0), coalesce($5, 0)), consigne_max_a = GREATEST(coalesce(consigne_max_a, 0), coalesce($6, 0)), @@ -421,7 +428,8 @@ async function ecrireSession(sql, client, st) { WHERE debut = $1`, [ouverte.debut, maintenant, p, dt, num(g('sensor.victron_evcs_current')?.state), - num(g('sensor.victron_evcs_set_current')?.state), vehicule]); + num(g('sensor.victron_evcs_set_current')?.state), vehicule, + txt(g('input_text.victron_evcs_source_mode')?.state).startsWith('Réseau') ? 1 : 0]); n++; } } else if (ouverte) {