# -*- 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 sql_grandeurs(refid, colonnes, calculees=None, fmt="time_series"): """Rapproche plusieurs grandeurs de la table `mesure` sur un axe commun. Deux difficultes, toutes deux resolues ici : 1. Le service horodate chaque mesure avec son propre `last_updated` — le vrai instant de la mesure, ce qui est le bon choix. Mais deux grandeurs ne partagent alors presque jamais le meme `ts` : un `GROUP BY ts` rend une ligne par grandeur, avec une seule colonne remplie. 2. Le service n'ecrit une mesure que si elle a change. Un simple regroupement a la minute laisse donc des trous, et toute colonne CALCULEE (une difference, par exemple) n'existe que les minutes ou toutes ses entrees ont bouge — 73 minutes sur 434 le 04/09. D'ou le report de la derniere valeur connue, qui est aussi ce que fait le controleur en temps reel : il decide sur la derniere valeur qu'il a lue. L'astuce `count(x) OVER (ORDER BY t)` numerote les paliers entre deux valeurs non nulles ; `first_value` reprend celle du debut du palier. """ noms = [n for _, n in colonnes] par_minute = ",\n".join( f" avg(valeur) FILTER (WHERE cle = '{c}') AS {n}" for c, n in colonnes) groupes = ",\n".join(f" count({n}) OVER (ORDER BY t) AS g_{n}" for n in noms) remplis = ",\n".join( f" first_value({n}) OVER (PARTITION BY g_{n} ORDER BY t) AS {n}" for n in noms) sorties = ",\n".join(f' {n} AS "{n.replace("_", " ")}"' for n in noms) if calculees: sorties += ",\n" + ",\n".join(f' {e} AS "{lib}"' for e, lib in calculees) q = (f"WITH par_minute AS (\n" f" SELECT date_trunc('minute', ts) AS t,\n{par_minute}\n" f" FROM mesure WHERE $__timeFilter(ts) GROUP BY 1\n" f"),\npaliers AS (\n SELECT t, {', '.join(noms)},\n{groupes}\n FROM par_minute\n" f"),\nreport AS (\n SELECT t,\n{remplis}\n FROM paliers\n)\n" f"SELECT t AS time,\n{sorties}\nFROM report ORDER BY t") return sql(refid, q, fmt=fmt) 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 stat(title, targets, ds, x, y, w, h, desc="", overrides=None, texte="auto", taille=None, mappings=None, champs=""): """Grands chiffres. Sur telephone, Grafana empile TOUS les panneaux en pleine largeur sous ~770 px : mettre plusieurs valeurs dans UN panneau est donc le seul moyen de les garder cote a cote et de limiter le defilement.""" return {"type": "stat", "title": title, "description": desc, "datasource": ds, "targets": targets, "gridPos": {"x": x, "y": y, "w": w, "h": h}, "fieldConfig": {"defaults": { "color": {"mode": "thresholds"}, "thresholds": {"mode": "absolute", "steps": [{"color": "text", "value": None}]}, "mappings": mappings or []}, "overrides": overrides or []}, "options": {"reduceOptions": {"calcs": ["lastNotNull"], # "" = champs numeriques seulement. # Les etats d'alarme sont du TEXTE : # il faut "/.*/" pour les afficher. "fields": champs, "values": False}, "orientation": "horizontal", "textMode": texte, "colorMode": "value", "graphMode": "none", "justifyMode": "center", "percentChangeColorMode": "standard", "noValue": "—", **({"text": taille} if taille else {}), "wideLayout": True}} 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", variables=None): return { "templating": {"list": variables or []}, "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_grandeurs("A", [ ('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, 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_grandeurs("A", [ ('sensor.victron_pv_power_system', 'Production'), ('sensor.victron_consumption_power_l1', 'Maison'), ('sensor.victron_evcs_ac_power', 'Voiture')], calculees=[('Production - Maison', 'Excedent')])], 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 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 date_trunc('minute', ts) AS time, avg(valeur) AS \"SOC\"\n" "FROM mesure WHERE cle = 'sensor.victron_battery_soc'\n" " AND $__timeFilter(ts) GROUP BY 1 ORDER BY 1"), sql("B", "SELECT date_trunc('minute', ts) AS time, avg(valeur) AS \"Courant\"\n" "FROM mesure WHERE cle = 'sensor.victron_battery_current'\n" " AND $__timeFilter(ts) GROUP BY 1 ORDER BY 1")], 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 ), -- INTEGRATION PONDEREE PAR LA DUREE, et non somme/60. -- Le service n'ecrit une mesure que lorsqu'elle change : compter les -- echantillons revient a supposer qu'il y en a 60 par heure alors qu'il y -- en a eu 26 a 47 le 04/09 : l'energie etait sous-estimee d'environ 9 %. -- Chaque point vaut donc son intervalle jusqu'au suivant, plafonne a 300 s -- pour qu'un arret du service n'extrapole pas une valeur perimee. pas AS ( SELECT ts, valeur, LEAST(EXTRACT(epoch FROM (lead(ts) OVER (ORDER BY ts) - ts)), 300) AS dt FROM mesure WHERE cle = 'sensor.victron_pv_power_system' AND $__timeFilter(ts) ), produit AS ( SELECT date_trunc('hour', ts) AS heure, sum(valeur * coalesce(dt, 0)) / 3600.0 / 1000.0 AS kwh FROM pas GROUP BY 1 ) SELECT to_char(coalesce(prevu.heure, produit.heure), 'DD/MM HH24') || ' h' AS "Heure", 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 coalesce(prevu.heure, produit.heure)""" 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\",\n" " to_char(jour, 'DD/MM') 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."), ]) # ===================================================================== # 4. TELEPHONE — une colonne, peu de panneaux, la reponse d'abord # ===================================================================== # Contrainte de conception : sous ~770 px de large, Grafana passe CHAQUE # panneau en pleine largeur et les empile. Le nombre de panneaux devient donc # le nombre d'ecrans a faire defiler. D'ou le choix de regrouper les chiffres # dans deux panneaux « stat » plutot que d'en faire six. def seuils(*paires): return {"mode": "absolute", "steps": [{"color": c, "value": v} for c, v in paires]} def sur(nom, props): return {"matcher": {"id": "byName", "options": nom}, "properties": props} telephone = dashboard("evcs-telephone", "Sur le telephone", ( "Une colonne, pensee pour un ecran de telephone. Repond a une seule " "question : est-ce que la nuit qui vient va bien se passer ?"), frm="now-12h", panels=[ stat("Maintenant", [ sql("A", "SELECT\n" " (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_battery_soc'\n" " ORDER BY ts DESC LIMIT 1) AS \"Batterie\",\n" " (SELECT soc_pct - soc_requis_pct FROM bilan\n" " WHERE soc_requis_pct IS NOT NULL ORDER BY ts DESC LIMIT 1) AS \"Marge\",\n" " (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_pv_power_system'\n" " ORDER BY ts DESC LIMIT 1)\n" "- (SELECT valeur FROM mesure WHERE cle = 'sensor.victron_consumption_power_l1'\n" " ORDER BY ts DESC LIMIT 1) AS \"Excedent\"", fmt="table")], PG, 0, 0, 24, 5, "Marge = ce que la batterie a, moins ce qu'il lui faut pour tenir jusqu'au " "relais du solaire. Negative, la nuit ne passe pas sans reseau.", overrides=[ sur("Batterie", [{"id": "unit", "value": "percent"}, {"id": "thresholds", "value": seuils( ("red", None), ("orange", 25), ("yellow", 40), ("green", 60))}]), sur("Marge", [{"id": "unit", "value": "percent"}, {"id": "decimals", "value": 1}, {"id": "thresholds", "value": seuils(("red", None), ("green", 0))}]), sur("Excedent", [{"id": "unit", "value": "watt"}, {"id": "decimals", "value": 0}, {"id": "thresholds", "value": seuils( ("blue", None), ("green", 0))}])]), stat("La nuit qui vient", [ sql("A", "SELECT round(energie_a_couvrir_kwh::numeric, 1) AS \"A couvrir\",\n" " round(soc_requis_pct::numeric) AS \"SOC requis\",\n" " heure_croisement AS \"Relais du solaire\"\n" "FROM bilan ORDER BY ts DESC LIMIT 1", fmt="table")], PG, 0, 5, 24, 5, "Energie a couvrir d'ici l'heure ou la production repasse au-dessus de la " "consommation, et le niveau de batterie qu'il faudrait pour y arriver.", overrides=[ sur("A couvrir", [{"id": "unit", "value": "kwatth"}]), sur("SOC requis", [{"id": "unit", "value": "percent"}, {"id": "thresholds", "value": seuils( ("green", None), ("yellow", 60), ("orange", 85), ("red", 100))}])]), ts("Ce qu'elle a, ce qu'il lui faut", [ sql("A", "SELECT ts AS time,\n" " soc_pct AS \"Batterie\",\n" " soc_requis_pct AS \"Requis\"\n" "FROM bilan WHERE $__timeFilter(ts) ORDER BY 1")], PG, 0, 10, 24, 7, "percent", "Quand le vert passe sous l'orange, le reseau sera necessaire.", legend="bottom", extra={"defaults": {"min": 0, "max": 100}, "overrides": [ sur("Batterie", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}, {"id": "custom.lineWidth", "value": 3}]), sur("Requis", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "orange"}}, {"id": "custom.fillOpacity", "value": 0}])]}), ts("Ce qui reste apres la maison", [ sql_grandeurs("A", [ ('sensor.victron_pv_power_system', 'Production'), ('sensor.victron_consumption_power_l1', 'Maison')], calculees=[('Production - Maison', 'Excedent')])], PG, 0, 17, 24, 7, "watt", "Au-dessus de zero, la voiture peut charger sans rien couter. En dessous, " "la charger revient a vider la batterie.", extra={"overrides": [ sur("Production", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "yellow"}}]), sur("Maison", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "text"}}, {"id": "custom.fillOpacity", "value": 0}]), sur("Excedent", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}, {"id": "custom.lineWidth", "value": 3}])]}), timeline("Le reseau", [ sql("A", "SELECT ts AS time,\n" " source_mode AS \"Source\",\n" " CASE WHEN session_engagee THEN 'engagee' ELSE '-' END AS \"Session\"\n" "FROM decision WHERE $__timeFilter(ts) ORDER BY 1", fmt="table")], PG, 0, 24, 24, 5, "Une seule bascule par nuit : c'est le but de la session unique.", mappings=[{"type": "value", "options": { "-": {"color": "dark-blue", "index": 0}, "engagee": {"color": "orange", "index": 1}}}]), table("Ce qui s'est passe", [ sql("A", "SELECT to_char(ts, 'DD/MM HH24:MI') AS \"Quand\",\n" " resume AS \"Quoi\"\n" "FROM evenement ORDER BY ts DESC LIMIT 8", fmt="table")], PG, 0, 29, 24, 7, "Les huit derniers evenements, quelle que soit la fenetre de temps.", 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", fenetre="24h"): """Derniere valeur connue d'une entite, renommee pour l'affichage.""" # LA FENETRE DEPEND DU COMPORTEMENT DE L'ENTITE AU REPOS, pas d'un # compromis unique. C'est la lecon de deux erreurs successives : # # - trop LARGE partout (7 jours) : le panneau Reseau affichait le 05/09 a # minuit « Courant 2,2 A », valeur ecrite quatorze heures plus tot. Les # capteurs du reseau passent en `unavailable` quand AC-IN disparait ; # ils ne tombent pas a zero, et leur derniere valeur numerique ment. # # - trop ETROITE partout (6 heures) : a 03:20 le panneau PV affichait # « No data » alors que la production est nulle et connue — sa derniere # ecriture datait du coucher du soleil, 370 minutes plus tot. # # Regle : une entite qui publie un VRAI zero au repos (production PV, borne # a l'arret, trackers) supporte une fenetre large, sa derniere valeur restant # exacte. Une entite qui DISPARAIT au repos exige une fenetre courte — c'est # le cas des seules grandeurs du reseau, qui passent donc 6h en argument. return flux(refid, f'from(bucket: "evcs")\n' f' |> range(start: -{fenetre})\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: ["_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 bornes AS ( SELECT $__timeFrom()::timestamptz AS d, $__timeTo()::timestamptz AS f ), -- Le pas s'adapte a la plage choisie. Sans cela, « 30 derniers jours » rend -- 720 barres illisibles — c'est exactement ce que fait VRM en passant de -- l'heure au jour puis au mois. 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 ), axe AS ( SELECT generate_series(date_trunc('minute', (SELECT d FROM bornes)), date_trunc('minute', (SELECT f FROM bornes)), 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(date_trunc(pas.unite, t), pas.forme) AS "Heure", """ + ",\n".join( # Energie REELLEMENT accumulee : puissance moyenne x duree observee. f' round((avg({n}) * count({n}) / 60.0 / 1000.0)::numeric, 2) AS "{n}"' for _, n in ENERGIE_CLES) + """ FROM report, pas GROUP BY date_trunc(pas.unite, t), pas.forme HAVING """ + " OR ".join(f"count({n}) > 0" for _, n in ENERGIE_CLES) + """ ORDER BY date_trunc(pas.unite, 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: -30m) |> 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) # Selecteur de vue, dans l'esprit de VRM : la meme representation, restreinte # aux grandeurs qui interessent. La valeur de chaque option est directement une # alternative d'expression reguliere, consommee par la transformation # « filterFieldsByName » des panneaux — d'ou le separateur « | ». _VUES = [ ("Vue generale", "Solaire|Reseau|Batterie|Maison|Voiture"), ("Consommation", "Maison|Voiture"), ("Reseau", "Reseau|Maison"), ("Batterie", "Batterie|Solaire|Maison"), ("Chargeur VE", "Voiture|Solaire"), ] VARIABLE_VUE = { "name": "vue", "label": "Vue", "type": "custom", "hide": 0, "skipUrlSync": False, "query": ",".join(f"{t} : {v}" for t, v in _VUES), "options": [{"selected": i == 0, "text": t, "value": v} for i, (t, v) in enumerate(_VUES)], "current": {"selected": True, "text": _VUES[0][0], "value": _VUES[0][1]}, "multi": False, "includeAll": False, } # « Time » avec une MAJUSCULE : c'est ainsi que la source PostgreSQL nomme le # champ temporel dans le format time_series. Ecrit en minuscules, le filtre # supprimait l'axe des temps et le panneau repondait « Data is missing a time # field ». Les deux graphies sont acceptees, la casse etant significative ici. FILTRE_VUE = [{"id": "filterFieldsByName", "options": {"include": {"pattern": "^(Time|time|Heure|$vue)$"}}}] 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-24h", refresh="30s", variables=[VARIABLE_VUE], panels=[ stat("Reseau", [ # 6 h et non 24 : ces trois capteurs DISPARAISSENT quand AC-IN s'ouvre, # laissant derriere eux une valeur qui ne vaut plus rien. flux_dernier("A", "W", "victron_grid_power_l1", "Puissance", fenetre="6h"), flux_dernier("B", "V", "airtria_mpii_v1_input_voltage_l1", "Tension", fenetre="6h"), flux_dernier("C", "A", "gx_device_grid_current_l1", "Courant", fenetre="6h")], IN, 0, 0, 5, 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, 5, 0, 4, 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, 9, 0, 10, 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")], # Cinq valeurs dans huit colonnes : Grafana reduisait le texte jusqu'a # l'illisible. Dix colonnes et une taille imposee, plutot que de laisser # l'ajustement automatique decider. taille={"valueSize": 26, "titleSize": 11}), 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, 5, 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, 5, 5, 13, 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}}}]), stat("Ce que le BMS impose", [ # Fenetre de 48 h pour ce panneau, et c'est deliberement different du # reste. Un etat d'alarme ne devient jamais « indisponible » : il se # publie. Sa derniere valeur EST donc la valeur courante, meme vieille de # dix heures — a l'inverse du courant reseau, dont le capteur disparait et # laisse derriere lui un chiffre qui ment. flux_dernier("A", "A", "airtria_8_us2000c_maximum_allowed_charge_current", "Charge max", fenetre="48h"), flux("B", 'from(bucket: "evcs")\n' ' |> range(start: -48h)\n' ' |> filter(fn: (r) => r._measurement == "sensor.airtria_8_us2000c_tension_de_cellule_elevee"' ' and r._field == "state")\n' ' |> last()\n' ' |> keep(columns: ["_value"])\n' ' |> rename(columns: {_value: "Tension cellule"})'), flux("C", 'from(bucket: "evcs")\n' ' |> range(start: -48h)\n' ' |> filter(fn: (r) => r._measurement == "sensor.airtria_8_us2000c_cell_imbalance"' ' and r._field == "state")\n' ' |> last()\n' ' |> keep(columns: ["_value"])\n' ' |> rename(columns: {_value: "Desequilibre"})'), flux_dernier("D", "%", "airtria_8_us2000c_state_of_health", "Sante", fenetre="48h")], IN, 18, 5, 6, 8, "« Charge max » est la limite que le BMS impose au systeme entier. Le " "04/09 elle est tombee de 150 a 20 A en trois minutes, des 76 % de charge, " "au moment ou l'alarme de tension de cellule s'est declenchee — le parc " "n'etait alors qu'a 50,76 V pour 53,2 V autorises. Ce n'est pas le parc " "qui sature, c'est une cellule.", overrides=[col("Charge max", "amp"), col("Sante", "percent")], taille={"valueSize": 24, "titleSize": 11}, champs="/.*/", mappings=[{"type": "value", "options": { "no_alarm": {"text": "OK", "color": "green", "index": 0}, "warning": {"text": "AVERTISSEMENT", "color": "orange", "index": 1}, "alarm": {"text": "ALARME", "color": "red", "index": 2}}}]), ts("Les flux", [ # fmt="table" et non "time_series" : le filtre de vue s'applique bien sur # une trame de type table — c'est ce que fait le graphique en barres — et # echouait sur time_series avec « Data is missing a time field ». Le # panneau time series accepte une trame table portant une colonne # temporelle, le rendu est identique. 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')], fmt="table")], 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"}}])], "transformations": FILTRE_VUE}), dict(barchart("Energie par periode", [sql("A", ENERGIE_SQL, fmt="table")], PG, 0, 22, 24, 9, "kwatth", "Le pas suit la periode choisie : l'heure jusqu'a 4 jours, le jour " "jusqu'a 70, le mois au-dela. 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)."), 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), ('vehicules.json', vehicules)]: (base / nom).write_text(json.dumps(d, indent=2, ensure_ascii=False) + '\n') print(f"{nom:16s} {len(d['panels'])} panneaux")