# -*- 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},\n" f" (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = '{c}'))[1]" f" AS d_{n}" for c, n in colonnes) groupes = ",\n".join( f" {n}, count(d_{n}) OVER (ORDER BY t) AS g_{n}" for n in noms) remplis = ",\n".join( f" coalesce({n}, first_value(d_{n}) OVER (PARTITION BY g_{n} ORDER BY t))" f" 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,\n{groupes},\n" + ",\n".join(f" d_{n}" for n in noms) + f"\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="", overrides=None): 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": overrides or []}, "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", jusqu="now", 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, # `jusqu` depasse « maintenant » sur les tableaux qui montrent une # prevision : sans cela la partie a venir tombe hors du cadre, et le # tableau ne montre que du passe. Les requetes de MESURE bornent alors # elles-memes leur fin a `now()` — voir energie_sql(). "time": {"from": frm, "to": jusqu}, "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"}, # Les evenements sont OPT-IN : `enable: False`. Ils rendent un # graphique illisible des qu'ils sont nombreux — le 06/09, sept # jours d'historique donnaient une foret de pointillies orange qui # masquait les courbes. Le bouton « Evenements » en tete de tableau # les rallume quand on veut le contexte d'un incident. # # Et meme allumes, on ecarte le genre `bilan` : a lui seul 26 % des # lignes, presque toutes des bascules de « production bridee » qui # changent plusieurs fois par heure. Ce qui merite un trait vertical # est ce qui ARRIVE — une bascule du contacteur, un vehicule # branche, un changement de mode de charge, un redemarrage, une # intervention manuelle — pas un drapeau qui clignote. {"name": "Evenements", "datasource": PG, "enable": False, "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) " "AND genre <> 'bilan' ORDER BY 1"}}]}, "panels": panels} # La prevision LA PLUS RECENTE, mise a plat en (heure locale, kWh), et bornee # aux heures STRICTEMENT a venir. # # ⚠️ L'heure EN COURS est volontairement exclue : elle est deja mesuree pour ses # minutes ecoulees. Y superposer une barre de prevision d'heure pleine ferait # compter deux fois la meme heure et donnerait un total faux. PREVISION_A_VENIR = """ SELECT (c.jour + (u.i - 1) * interval '1 hour') AT TIME ZONE 'Europe/Paris' AS heure, u.v AS kwh FROM (SELECT DISTINCT ON (jour) jour, horaire FROM prevision WHERE jour >= current_date ORDER BY jour, publie_a DESC) c, unnest(c.horaire) WITH ORDINALITY AS u(v, i) WHERE (c.jour + (u.i - 1) * interval '1 hour') AT TIME ZONE 'Europe/Paris' > date_trunc('hour', now()) """ # ===================================================================== # 1. LA NUIT — la question a laquelle on ne savait pas repondre le 03/09 # ===================================================================== # ===================================================================== # 1bis. LES NUITS SUPERPOSEES (28/09/2026) — demande du proprietaire # ===================================================================== # « Si tu superposes 20 nuits tu dois trouver un nuage de points. » Le bilan # prevoit la nuit avec un profil horaire MOYEN, aveugle a la soiree qui vient # de se passer ; or sur 24 nuits, une soiree chargee (four, 18:00-21:30) # annonce une nuit chargee (r2 = 0,40, 28/09). Ces panneaux rendent la relation # visible, et serviront a verifier le bilan le jour ou il l'utilisera. # Energie = puissance tenue jusqu'au point suivant (capteurs ecrits sur # changement), plafond 1 h pour qu'un trou n'extrapole rien — meme methode que # le rapport de nuit (§76). « Maison » = consommation AC moins la voiture. # Chiffres verifies contre un calcul independant (script) le 28/09 : identiques. NUITS_CTE = pathlib.Path(__file__).with_name('nuits-maison.sql').read_text() PROFIL_SQL = pathlib.Path(__file__).with_name('grafana-profil-horaire.sql').read_text() def nuits_sql(select): return NUITS_CTE.rstrip() + "\n" + select # UNE seule trame, colonnes X puis Y (mapping automatique : premiere colonne # numerique = X). La premiere version (4 requetes, mapping manuel par trame) # affichait « Err » dans Grafana 11.3. La droite est calculee pour CHAQUE nuit : # elle passe par toutes les abscisses, donc aucun trou ne la coupe. NUAGE = """, f AS ( SELECT ((CASE WHEN (now() AT TIME ZONE 'Europe/Paris')::time >= time '18:00' THEN (now() AT TIME ZONE 'Europe/Paris')::date ELSE (now() AT TIME ZONE 'Europe/Paris')::date - 1 END) + time '18:00') AT TIME ZONE 'Europe/Paris' AS s0 ), soir AS ( SELECT sum(p.valeur * extract(epoch FROM least(p.fin, f.s0 + interval '3 hours 30 minutes', now()) - greatest(p.ts, f.s0)) * CASE WHEN p.cle = 'sensor.victron_evcs_ac_power' THEN -1 WHEN p.cle = 'sensor.victron_consumption_power_l1' THEN 1 ELSE 0 END) / 3.6e6 AS kwh FROM pts p, f WHERE p.fin > f.s0 AND p.ts < least(f.s0 + interval '3 hours 30 minutes', now()) ), r AS (SELECT regr_intercept(maison_nuit_kwh, soiree_kwh) AS a, regr_slope(maison_nuit_kwh, soiree_kwh) AS b FROM nuits) SELECT soiree_kwh::float8 AS "Soiree", CASE WHEN voiture_kwh < 0.5 THEN maison_nuit_kwh END::float8 AS "Nuit sans voiture", CASE WHEN voiture_kwh >= 0.5 THEN maison_nuit_kwh END::float8 AS "Nuit avec voiture", round((r.a + r.b * soiree_kwh)::numeric, 2)::float8 AS "Regression", NULL::float8 AS "Ce soir (prevu)" FROM nuits, r UNION ALL SELECT round(s.kwh::numeric, 2)::float8, NULL, NULL, round((r.a + r.b * s.kwh)::numeric, 2)::float8, round((r.a + r.b * s.kwh)::numeric, 2)::float8 FROM soir s, r ORDER BY 1""" # ⚠️ `pluginVersion` OBLIGATOIRE : Grafana 11.3 a `autoMigrateXYChartPanel` actif et # traite un panneau xychart SANS version comme un ancien (v1) qu'il convertit — # la conversion casse une configuration deja au format v2 (« Err », 28/09). NUIT_XY = {"type": "xychart", "pluginVersion": "11.3.0", "title": "Soiree contre nuit : le nuage des nuits", "description": ("Un point = une nuit. En abscisse ce que la maison a consomme de 18:00 a " "21:30 (la cuisine), en ordonnee de 21:30 a 09:00, voiture exclue. La " "droite orange est la regression sur toutes les nuits ; le gros point rouge " "est CE SOIR, place sur la droite : une prevision, pas une mesure. " "1 point de SOC = 0,30 kWh sur le parc actuel."), "datasource": PG, "gridPos": {"x": 0, "y": 41, "w": 14, "h": 11}, "targets": [sql("A", NUITS_CTE.rstrip() + NUAGE, fmt="table")], "fieldConfig": {"defaults": {"unit": "kwatth", "color": {"mode": "palette-classic"}, "custom": {"show": "points", "pointSize": {"fixed": 9}, "pointShape": "circle", "lineWidth": 2, "fillOpacity": 60}}, "overrides": [ {"matcher": {"id": "byName", "options": "Nuit sans voiture"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}]}, {"matcher": {"id": "byName", "options": "Nuit avec voiture"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "blue"}}]}, {"matcher": {"id": "byName", "options": "Regression"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "orange"}}, {"id": "custom.show", "value": "lines"}]}, {"matcher": {"id": "byName", "options": "Ce soir (prevu)"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}, {"id": "custom.pointSize", "value": {"fixed": 18}}]}]}, # Mapping MANUEL, quatre series sur la MEME trame (index 0). L'automatique # n'affichait que la premiere colonne Y (28/09, capture du proprietaire) ; la # premiere version, une trame par requete, affichait « Err ». "options": {"mapping": "manual", "series": [{"frame": {"matcher": {"id": "byIndex", "options": 0}}, "x": {"matcher": {"id": "byName", "options": "Soiree"}}, "y": {"matcher": {"id": "byName", "options": n}}} for n in ["Nuit sans voiture", "Nuit avec voiture", "Regression", "Ce soir (prevu)"]], "legend": {"showLegend": True, "displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "single", "sort": "none"}}} NUIT_TABLE = table("Les nuits, une ligne par nuit", [ sql("A", nuits_sql( 'SELECT to_char(j, \'DD/MM\') AS "Nuit du", soc_2130 AS "SOC 21:30", ' 'soc_0124 AS "SOC 01:24", soc_9h AS "SOC 09:00", soiree_kwh AS "Soiree 18-21:30", ' 'maison_hc_kwh AS "Maison 21:30-01:24", maison_nuit_kwh AS "Maison 21:30-09:00", ' 'voiture_kwh AS "Voiture", reseau_kwh AS "Reseau", pv_avant_9h_kwh AS "PV avant 9h" ' 'FROM nuits ORDER BY j DESC'), fmt="table")], PG, 14, 41, 10, 11, "kWh ; « Maison » = voiture exclue. La nuit en cours apparait le lendemain a " "09:00, une fois close. C'est la matiere du nuage de gauche.") NUIT_PROFIL = barchart("Profil horaire de la maison : 20 nuits contre cette nuit", [ sql("A", PROFIL_SQL, fmt="table")], PG, 0, 52, 24, 9, "kwatth", "kWh consommes par la maison (voiture exclue) heure par heure, de 18 h a 17 h. " "Mediane et valeur haute (8 nuits sur 10 en dessous) sur 20 jours, et la nuit en " "cours par-dessus — l'heure en cours est partielle. C'est ce profil moyen que le " "bilan utilise ; une soiree au-dessus de la mediane annonce une nuit au-dessus.", overrides=[{"matcher": {"id": "byName", "options": "Cette nuit"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}]}, {"matcher": {"id": "byName", "options": "Mediane 20 nuits"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}]}, {"matcher": {"id": "byName", "options": "8 nuits sur 10 sous"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "orange"}}]}]) 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"}}}), # Budget voiture de la nuit (30/09/2026) : ce que la batterie peut donner sans # EDF, fige a 01:24, contre ce que la borne a reellement donne. ts("Voiture sur la batterie : budget contre donne", [ sql("A", "SELECT ts AS time, valeur AS \"Budget prevu (bilan)\"\n" "FROM mesure\n" "WHERE cle = 'input_number.evcs_balance_night_vehicle_budget_kwh'\n" " AND $__timeFilter(ts)\n" "ORDER BY 1"), sql("B", "SELECT m.ts AS time, m.valeur - b.valeur AS \"Donne depuis 01:24\"\n" "FROM mesure m\n" "JOIN LATERAL (\n" " SELECT valeur FROM mesure\n" " WHERE cle = m.cle\n" " AND ts <= ((((m.ts AT TIME ZONE 'Europe/Paris') - interval '84 minutes')::date\n" " + time '01:24') AT TIME ZONE 'Europe/Paris')\n" " ORDER BY ts DESC LIMIT 1) b ON true\n" "WHERE m.cle = 'sensor.evcs_hq2239dnt7p_total_energy'\n" " AND $__timeFilter(m.ts)\n" " AND (m.ts AT TIME ZONE 'Europe/Paris')::time BETWEEN time '01:24' AND time '09:00'\n" "ORDER BY 1"), sql("C", "SELECT ts AS time,\n" " soc_pct AS \"SOC batterie\",\n" " soc_requis_prudent_pct AS \"Plancher prudent\"\n" "FROM bilan\n" "WHERE $__timeFilter(ts)\n" "ORDER BY 1")], PG, 0, 24, 24, 9, "kwatth", "Le budget est ce que la voiture peut prendre sur la batterie SANS EDF " "(profil prudent, plancher 22 %, la batterie tient jusqu'a la nuit " "suivante). Le controleur le FIGE a 01:24 : sa valeur a 01:24 est celle " "qui compte. « Donne depuis 01:24 » = compteur de la borne depuis " "l'ouverture des heures creuses. En « sans EDF », la charge doit " "s'arreter quand la courbe bleue atteint le budget ; en « charge " "complete », EDF finit le reste. Axe de droite : la batterie et son " "plancher prudent.", extra={"defaults": {"custom": {"lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": True}}, "overrides": [ {"matcher": {"id": "byName", "options": "Budget prevu (bilan)"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "orange"}}, {"id": "custom.lineInterpolation", "value": "stepAfter"}, {"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [8, 6]}}]}, {"matcher": {"id": "byName", "options": "Donne depuis 01:24"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "blue"}}, {"id": "custom.lineWidth", "value": 3}, {"id": "custom.fillOpacity", "value": 15}]}, {"matcher": {"id": "byRegexp", "options": "SOC batterie|Plancher prudent"}, "properties": [{"id": "unit", "value": "percent"}, {"id": "min", "value": 0}, {"id": "max", "value": 100}, {"id": "custom.axisPlacement", "value": "right"}]}, {"matcher": {"id": "byName", "options": "SOC batterie"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}]}, {"matcher": {"id": "byName", "options": "Plancher prudent"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}, {"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [4, 4]}}]}]}), 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, 33, 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}]}]), NUIT_XY, NUIT_TABLE, NUIT_PROFIL, ]) # ===================================================================== # 2. LE SOLAIRE — excedent, censure, et ce que les chargeurs disent # ===================================================================== # Rendement journalier declare par chaque chargeur (MANUEL §0 : la production # reelle du jour est leur SOMME, jamais un sous-tracker du 450/200 en plus). RENDEMENTS_FLUX = ( 'from(bucket: "evcs")\n' ' |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n' ' |> filter(fn: (r) => r._measurement == "kWh" and r._field == "value")\n' ' |> filter(fn: (r) => r.entity_id == "airtria_mppt1_yield_today"\n' ' or r.entity_id == "mppt_450_200_hq2449yr9pt_yield_today"\n' ' or r.entity_id == "v1_yield_today")\n' ' |> aggregateWindow(every: v.windowPeriod, fn: last, createEmpty: false)\n' ' |> map(fn: (r) => ({ r with chargeur:\n' ' if r.entity_id =~ /^airtria/ then "MPPT 250/70 sud-est"\n' ' else if r.entity_id =~ /^mppt_450/ then "RS 450/200 sud + ouest"\n' ' else "150/35 suiveur" }))\n' ' |> group()\n') 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 =~ /\\.v1_/ then "Suiveur"\n' ' else 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("Production du jour par chargeur", [ flux("A", RENDEMENTS_FLUX + ' |> keep(columns: ["_time", "_value", "chargeur"])\n' ' |> pivot(rowKey: ["_time"], columnKey: ["chargeur"], valueColumn: "_value")\n' ' |> sort(columns: ["_time"])'), flux("B", RENDEMENTS_FLUX + ' |> keep(columns: ["_time", "_value", "chargeur"])\n' ' |> pivot(rowKey: ["_time"], columnKey: ["chargeur"], valueColumn: "_value")\n' ' |> sort(columns: ["_time"])\n' ' |> fill(usePrevious: true, column: "MPPT 250/70 sud-est")\n' ' |> fill(usePrevious: true, column: "RS 450/200 sud + ouest")\n' ' |> fill(usePrevious: true, column: "150/35 suiveur")\n' ' |> filter(fn: (r) => exists r["MPPT 250/70 sud-est"] and exists r["RS 450/200 sud + ouest"]\n' ' and exists r["150/35 suiveur"])\n' ' |> map(fn: (r) => ({ _time: r._time, Total: r["MPPT 250/70 sud-est"]\n' ' + r["RS 450/200 sud + ouest"] + r["150/35 suiveur"] }))')], IN, 0, 23, 24, 8, "kwatth", "Le rendement du jour que chaque chargeur declare lui-meme (yield_today), " "remis a zero chaque nuit. C'est la production REELLE (MANUEL §0), la meme " "que sensor.evcs_production_jour_kwh et que l'agent vocal. Le Total " "n'apparait que si les trois chargeurs publient : un chargeur muet donne " "un total partiel, qu'on ne trace pas. Les trois ne se remettent pas a " "zero a la meme minute (vers 00:40). Le suiveur n'est exporte que depuis " "le 25/09.", extra={"overrides": [ {"matcher": {"id": "byName", "options": "Total"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "yellow"}}, {"id": "custom.lineWidth", "value": 3}, {"id": "custom.fillOpacity", "value": 0}]}]}), ts("RS 450/200 — les quatre entrees", [ flux("A", 'from(bucket: "evcs")\n' ' |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n' ' |> filter(fn: (r) => r._measurement == "kWh" and r._field == "value")\n' ' |> filter(fn: (r) => r.entity_id =~ /^mppt_450_200_hq2449yr9pt_tracker_solar_[1-4]_yield_today$/)\n' ' |> aggregateWindow(every: v.windowPeriod, fn: last, createEmpty: false)\n' ' |> map(fn: (r) => ({ r with entree:\n' ' if r.entity_id =~ /solar_1/ then "Sud string 1"\n' ' else if r.entity_id =~ /solar_2/ then "Sud string 2"\n' ' else if r.entity_id =~ /solar_3/ then "Sud string 3"\n' ' else "Ouest" }))\n' ' |> group()\n' ' |> keep(columns: ["_time", "_value", "entree"])\n' ' |> pivot(rowKey: ["_time"], columnKey: ["entree"], valueColumn: "_value")\n' ' |> sort(columns: ["_time"])')], IN, 0, 31, 24, 8, "kwatth", "Rendement du jour de chaque entree du RS 450/200 : trois strings sud de " "10 panneaux a 35 degres, et l'ouest (9 panneaux, 45 degres). Les trois " "sud doivent rester SUPERPOSEES : une courbe qui decroche signale un " "defaut de string (ombre, connecteur, panneau). Ces quatre valeurs " "detaillent le total du RS, elles ne s'y ajoutent pas. Resolution 0,1 kWh.", extra={"overrides": [ {"matcher": {"id": "byName", "options": "Ouest"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "orange"}}]}]}), 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\",\n" " (SELECT round(coalesce(sum(kwh), 0)::numeric, 1)\n" " FROM (" + PREVISION_A_VENIR + ") p\n" " WHERE p.heure < date_trunc('day', now()) + interval '1 day')\n" " AS \"Soleil restant\"", 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. " "« Soleil restant » est le seul chiffre de ce bandeau qui ne soit pas une " "mesure : c'est la derniere prevision pour les heures qui restent " "aujourd'hui, l'heure en cours exclue.", 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))}]), sur("Soleil restant", [{"id": "unit", "value": "kwatth"}, {"id": "decimals", "value": 1}, {"id": "color", "value": {"mode": "fixed", "fixedColor": "#9e9e9e"}}])]), 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" " (SELECT round(coalesce(sum(kwh), 0)::numeric, 1)\n" " FROM (" + PREVISION_A_VENIR + ") p\n" " WHERE p.heure >= date_trunc('day', now()) + interval '1 day')\n" " AS \"Soleil demain\"\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. " "« Soleil demain » dit de combien la batterie pourra se refaire une fois " "la nuit passee : c'est lui qui decide jusqu'ou on peut la laisser " "descendre.", 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))}]), sur("Soleil demain", [{"id": "unit", "value": "kwatth"}, {"id": "decimals", "value": 1}, {"id": "color", "value": {"mode": "fixed", "fixedColor": "#9e9e9e"}}])]), 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. def energie_sql(prevision=False): """Energie par periode. Avec `prevision`, une colonne « Encore attendu » prolonge le graphique sur les heures a venir.""" return """WITH bornes AS ( -- ⚠️ LA MESURE NE PEUT PAS DEPASSER MAINTENANT. « La journee » s'etend -- desormais jusqu'a la fin du jour pour montrer la prevision ; sans ce -- garde-fou, le report de la derniere valeur connue (plus bas) remplirait les -- heures a venir avec la puissance de l'instant et gonflerait l'energie. SELECT $__timeFrom()::timestamptz AS d, least($__timeTo()::timestamptz, now()) 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},\n" f" (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = '{c}'))[1] AS d_{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}, releve.d_{n}" for _, n in ENERGIE_CLES) + """ FROM axe LEFT JOIN releve USING (t) ), paliers AS ( SELECT t, """ + ", ".join( f"{n}, d_{n}" for _, n in ENERGIE_CLES) + ",\n" + ",\n".join( f" count(d_{n}) OVER (ORDER BY t) AS g_{n}" for _, n in ENERGIE_CLES) + """ FROM par_minute ), report AS ( SELECT t, """ + ",\n".join( f" coalesce({n}, first_value(d_{n}) OVER (PARTITION BY g_{n} ORDER BY t)) AS {n}" for _, n in ENERGIE_CLES) + """ FROM paliers ), mesure_par_periode AS ( SELECT date_trunc(pas.unite, t) AS b, """ + ",\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) HAVING """ + " OR ".join(f"count({n}) > 0" for _, n in ENERGIE_CLES) + """ ) """ + (""" , a_venir AS ( SELECT date_trunc((SELECT unite FROM pas), p.heure) AS b, sum(p.kwh) AS kwh FROM (""" + PREVISION_A_VENIR + """) p WHERE p.heure >= (SELECT d FROM bornes) -- Borne STRICTE : une periode qui commence pile a la fin de la fenetre -- appartient a la suivante. Sans cela, « La journee » affiche une barre -- de demain 00 h. AND p.heure < $__timeTo()::timestamptz GROUP BY 1 ) -- FULL OUTER : une periode a venir n'a AUCUNE ligne de mesure, une periode -- passee n'a aucune prevision. Ni l'une ni l'autre ne doit disparaitre. SELECT to_char(coalesce(m.b, a.b), (SELECT forme FROM pas)) AS "Heure", """ + ",\n".join(f' m."{n}"' for _, n in ENERGIE_CLES) + """, round(a.kwh::numeric, 2) AS "Encore attendu" FROM mesure_par_periode m FULL OUTER JOIN a_venir a ON a.b = m.b ORDER BY coalesce(m.b, a.b)""" if prevision else """ SELECT to_char(b, (SELECT forme FROM pas)) AS "Heure", """ + ",\n".join(f' "{n}"' for _, n in ENERGIE_CLES) + """ FROM mesure_par_periode ORDER BY b""") ENERGIE_SQL = energie_sql() # 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.)" })) sv = base |> filter(fn: (r) => r.entity_id == "v1_pv_bus_voltage" or r.entity_id == "v1_pv_current" or r.entity_id == "v1_pv_yield_power") |> map(fn: (r) => ({ r with tracker: "Suiveur (3 pan.)" })) union(tables: [rs, se, sv]) |> 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, 7, "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, 7, "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, 9, 7, "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": 22, "titleSize": 11}), dict(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"), sql("D", "-- Le compteur de la BORNE, repris de la table des sessions : il\n" "-- compte depuis le vrai debut, la ou une integration ne couvre que\n" "-- ce que le service a vu. Session close depuis plus de 2 h : rien.\n" "SELECT round(coalesce(energie_station_kwh, energie_kwh)::numeric, 2) AS \"Session\"\n" "FROM session_charge\n" "WHERE fin > now() - interval '2 hours'\n" "ORDER BY debut DESC LIMIT 1", fmt="table")], IN, 18, 0, 6, 7, "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), sur("Session", [{"id": "unit", "value": "kwatth"}, {"id": "decimals", "value": 2}, {"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}])], taille={"valueSize": 22, "titleSize": 11}), # Sources melangees : le panneau porte « Mixed », chaque requete la sienne. datasource={"type": "datasource", "uid": "-- Mixed --"}), 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, 7, 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, 7, 13, 8, "Les 52 panneaux en quatre plans : sud-est 40 degres, trois chaines sud " "35 degres, ouest 45 degres, et le suiveur de trois panneaux. Le suiveur " "n'est exporte vers InfluxDB que depuis le 25/09 (il manquait a la liste " "d'export de Home Assistant, et non faute de publier).", 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, 7, 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, 15, 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, 24, 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(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule = 'VW'), 0)::numeric, 2) AS "VW", round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule = 'JEEP'), 0)::numeric, 2) AS "JEEP", round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule = 'ZERO'), 0)::numeric, 2) AS "ZERO", round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule NOT IN ('VW', 'JEEP', 'ZERO')), 0)::numeric, 2) AS "Invite", round(coalesce(sum(coalesce(energie_station_kwh, 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)""" COULEURS_VEHICULES = {"VW": "blue", "JEEP": "green", "ZERO": "purple", "Invite": "yellow", "Non etiquete": "orange"} 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. " "Vehicules : VW, JEEP, ZERO (moto) ; « Invite » = tout autre (Megane de " "service, amis)."), frm="now-7d", refresh="5m", panels=[ table("Sur la periode", [ sql("A", "SELECT round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule = 'VW'), 0)::numeric, 1) AS \"VW\",\n" " round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule = 'JEEP'), 0)::numeric, 1) AS \"JEEP\",\n" " round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule = 'ZERO'), 0)::numeric, 1) AS \"ZERO\",\n" " round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule NOT IN ('VW', 'JEEP', 'ZERO')), 0)::numeric, 1) AS \"Invite\",\n" " round(coalesce(sum(coalesce(energie_station_kwh, energie_kwh)) FILTER (WHERE vehicule IS NULL), 0)::numeric, 1) AS \"Non etiquete\",\n" " count(*) AS \"Sessions\",\n" " count(*) FILTER (WHERE NOT vehicule_sur OR vehicule IS NULL) AS \"A confirmer\"\n" "FROM session_charge WHERE $__timeFilter(debut)", fmt="table")], PG, 0, 0, 24, 4, "kWh charges par vehicule sur la plage choisie (compteur de la borne). " "« Invite » = tout vehicule hors foyer (Megane de service, amis). " "« A confirmer » compte les sessions dont l'etiquette vient du selecteur " "sans confirmation : la seule mesure honnete de l'incertitude.", overrides=[sur(n, [{"id": "unit", "value": "kwatth"}, {"id": "decimals", "value": 1}, {"id": "custom.cellOptions", "value": {"type": "color-text"}}, {"id": "color", "value": {"mode": "fixed", "fixedColor": c}}]) for n, c in COULEURS_VEHICULES.items()] + [ sur("A confirmer", [{"id": "custom.cellOptions", "value": {"type": "color-text"}}, {"id": "thresholds", "value": seuils(("green", None), ("orange", 1))}, {"id": "color", "value": {"mode": "thresholds"}}])]), barchart("Energie par periode et par vehicule", [sql("A", SESSIONS_PERIODE, fmt="table")], PG, 0, 4, 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.", overrides=[sur(n, [{"id": "color", "value": {"mode": "fixed", "fixedColor": c}}]) for n, c in COULEURS_VEHICULES.items()]), 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 'confirme' ELSE 'a confirmer' END AS \"Etiquette\",\n" " round((extract(epoch FROM fin - debut) / 60)::numeric) AS \"Minutes\",\n" " round(coalesce(energie_station_kwh, energie_kwh)::numeric, 2) AS \"kWh\",\n" " round(energie_kwh::numeric, 2) AS \"kWh integres\",\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, 13, 24, 11, "« kWh » vient du compteur de la BORNE, qui compte depuis le vrai debut " "de la session. « kWh integres » est mon propre calcul, qui ne couvre que " "ce que le service a vu — un ecart entre les deux signale un demarrage ou " "un redemarrage en cours de session, pas une erreur de mesure. " "« 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}, "ZERO": {"color": "purple", "index": 2}, "?": {"color": "orange", "index": 3}}}]}]), sur("Doute", [{"id": "custom.width", "value": 150}]), sur("kWh", [{"id": "unit", "value": "kwatth"}]), sur("kWh integres", [{"id": "unit", "value": "kwatth"}, {"id": "custom.width", "value": 110}]), sur("dont reseau", [{"id": "unit", "value": "kwatth"}]), sur("P max", [{"id": "unit", "value": "watt"}])]), ]) # ===================================================================== # 7. LES BATTERIES — ce qu'aucun capteur Victron ne publie # ===================================================================== # Le GX n'expose que l'agregat du parc : une tension, un courant, un SOC. Les # quinze tensions de cellule d'un module ne sortent QUE par la console serie de # son BMS. C'est pour cela que le desequilibre est reste invisible des annees. VARIABLE_MODULE = { "name": "module", "label": "Module", "type": "query", "datasource": PG, "refresh": 1, "includeAll": False, "multi": False, # Repere = pack x 10 + rang (11 = pack 1 module 1, 21 = pack 2 module 1) : # chaque chaine numerote ses modules a partir de 1, le rang seul est ambigu. "query": ("SELECT __text, __value FROM (" "SELECT DISTINCT ON (module) " "CASE WHEN pack IS NOT NULL AND rang IS NOT NULL " "THEN (pack * 10 + rang) || ' \u00b7 ' || module " "WHEN rang IS NOT NULL THEN rang || ' \u00b7 ' || module " "ELSE module END AS __text, " "module AS __value FROM cellule ORDER BY module, ts DESC) v " "ORDER BY __text"), } # Echelle d'alarme du materiel, lue dans `sysinfo` du LVHUB le 05/09/2026. # Ce ne sont pas des valeurs choisies ici. SEUILS_CELLULE = seuils(("green", None), ("#EAB839", 3480), ("orange", 3520), ("red", 3550), ("dark-red", 3600)) batteries = dashboard("evcs-batteries", "Les batteries", ( "Les quinze tensions de cellule de chaque module Pylontech, relevees sur la " "console de leur BMS. Aucune de ces valeurs n'existe cote Victron, qui ne " "publie que l'agregat du parc. Les paliers de couleur sont ceux du materiel " "lui-meme : 3480, 3520, 3550 et 3600 mV."), [ table("Sante des modules — tous, d'un coup d'oeil", [ sql("A", "WITH vivants AS (\n" " SELECT module FROM cellule GROUP BY module\n" " HAVING max(ts) > now() - interval '2 hours'),\n" "dernier AS (\n" " SELECT DISTINCT ON (module) module, ts,\n" " CASE WHEN pack IS NOT NULL AND rang IS NOT NULL THEN pack * 10 + rang\n" " ELSE rang END AS rang FROM cellule\n" " WHERE module IN (SELECT module FROM vivants)\n" " ORDER BY module, ts DESC),\n" "maintenant AS (\n" " SELECT c.module, max(c.mv) - min(c.mv) AS ecart,\n" " max(c.mv) AS haute, min(c.mv) AS basse,\n" " max(c.courant_ma) AS ma, d.ts, d.rang\n" " FROM cellule c JOIN dernier d ON d.module = c.module AND d.ts = c.ts\n" " GROUP BY c.module, d.ts, d.rang),\n" "periode AS (\n" " SELECT module, max(ecart) AS ecart_max FROM (\n" " SELECT module, ts, max(mv) - min(mv) AS ecart\n" " FROM cellule WHERE $__timeFilter(ts) GROUP BY module, ts) x\n" " GROUP BY module)\n" "SELECT m.rang AS \"Repere\", m.module AS \"Module\",\n" " m.ecart AS \"Ecart maintenant\",\n" " p.ecart_max AS \"Ecart max sur la periode\",\n" " m.haute AS \"Plus haute\", m.basse AS \"Plus basse\",\n" " round(m.ma / 1000.0, 1) AS \"Courant (A)\",\n" " to_char(m.ts, 'DD/MM HH24:MI') AS \"Dernier releve\"\n" " FROM maintenant m LEFT JOIN periode p USING (module)\n" " ORDER BY m.ecart DESC", fmt="table")], PG, 0, 0, 24, 8, desc="Le resume qu'on lit en premier, tous modules ensemble — les autres " "panneaux demandent de choisir un module d'avance. L'ecart entre la " "cellule la plus haute et la plus basse est l'indicateur qui resume " "un module en un nombre : sous 20 mV il est sain, au-dela de 150 mV " "le BMS reduit le courant de charge de TOUT le parc. La colonne " "« Courant » dit si le module etait en charge au moment du releve : " "un ecart ne se compare qu'a courant comparable. « Rang » est " "l'adresse du module dans SA chaine au dernier releve — chaque " "chaine numerote a partir de 1, l'identite reste le code-barres.", overrides=[ sur("Ecart maintenant", [{"id": "unit", "value": "mvolt"}, {"id": "custom.cellOptions", "value": {"type": "color-background"}}, {"id": "thresholds", "value": seuils( ("green", None), ("#EAB839", 20), ("orange", 80), ("red", 150))}]), sur("Ecart max sur la periode", [{"id": "unit", "value": "mvolt"}, {"id": "custom.cellOptions", "value": {"type": "color-text"}}, {"id": "thresholds", "value": seuils( ("green", None), ("#EAB839", 50), ("orange", 150), ("red", 300))}]), sur("Plus haute", [{"id": "unit", "value": "mvolt"}, {"id": "thresholds", "value": SEUILS_CELLULE}, {"id": "custom.cellOptions", "value": {"type": "color-text"}}]), sur("Plus basse", [{"id": "unit", "value": "mvolt"}]), sur("Courant (A)", [{"id": "unit", "value": "amp"}, {"id": "decimals", "value": 1}])]), ts("Cellule par cellule — $module", [ sql("A", "SELECT ts AS time, 'cellule ' || lpad(cellule::text, 2, '0') AS metric,\n" " mv\n" "FROM cellule\n" "WHERE module = '$module' AND $__timeFilter(ts)\n" "ORDER BY 1, 2")], PG, 0, 8, 24, 10, unit="mvolt", desc="Une courbe par cellule. Une cellule saine suit exactement ses " "quatorze voisines ; une cellule qui a perdu de la capacite s'en " "detache en haut de charge ET en bas de decharge, parce qu'elle se " "remplit et se vide avant les autres.", extra={"fieldConfig": {"defaults": {"custom": {"lineWidth": 1}, "thresholds": SEUILS_CELLULE, "custom.thresholdsStyle": {"mode": "dashed"}}}}, legend="right"), # ⚠️ Ces deux panneaux COMPARENT les modules entre eux : ils ne doivent # montrer que les modules ENCORE SUR LA CHAINE. Le releveur lit aussi les # modules isoles tant qu'ils sont sous tension — le pack ancien a cesse # d'emettre le 05/09 mais restait affiche sur une fenetre de sept jours, # Sans lui, « le detail de chaque cellule pendant la charge » n'est pas # lisible : rien ne dit sur le panneau precedent QUAND le module chargeait. # Or un ecart entre cellules ne veut rien dire hors de son courant — le 05/09 # la cellule meneuse etait a 3555 mV sous 50 A et a 3496 mV sous 30 A, meme # module, meme tension de parc. # ⚠️ Le courant est lu DANS la table des cellules, pas dans celle du parc : # c'est le courant du MODULE, et c'est ce qui permet de voir qu'un module se # charge moins que ses voisins. Il n'est renseigne que depuis le 17/09/2026 ; # avant cette date la courbe est vide, et c'est un fait et non une panne. ts("Le module chargeait-il ? — $module", [ sql("A", "SELECT ts AS time, 'courant du module' AS metric,\n" " courant_ma / 1000.0 AS a\n" "FROM cellule\n" "WHERE module = '$module' AND cellule = 1\n" " AND courant_ma IS NOT NULL AND $__timeFilter(ts)\n" "ORDER BY 1")], PG, 0, 18, 24, 5, unit="amp", desc="Positif en charge, negatif en decharge. A lire en regard du panneau " "au-dessus : l'ecart entre cellules s'ouvre avec le courant, et une " "cellule abimee ne se trahit qu'en charge.", extra={"defaults": {"custom": {"fillOpacity": 15, "lineWidth": 1}}}), # soit huit courbes dont quatre mortes. Le critere est « a emis dans les # deux dernieres heures », pas un prefixe de code-barres : rebrancher un # pack le fait revenir tout seul. ts("Ecart entre la plus haute et la plus basse", [ sql("A", "SELECT ts AS time, module AS metric, max(mv) - min(mv) AS ecart\n" "FROM cellule WHERE $__timeFilter(ts)\n" " AND module IN (SELECT module FROM cellule\n" " GROUP BY module HAVING max(ts) > now() - interval '2 hours')\n" "GROUP BY ts, module ORDER BY 1")], PG, 0, 23, 12, 8, unit="mvolt", desc="L'indicateur qui resume un module en une courbe. Un module sain de " "ce parc tient sous 20 mV ; au-dela de 150 mV le BMS commence a " "reduire le courant de charge de TOUT le parc."), ts("La cellule meneuse, contre le plafond que le BMS impose", [ sql("A", "SELECT ts AS time, module || ' — cellule meneuse' AS metric,\n" " max(mv) AS mv\n" "FROM cellule WHERE $__timeFilter(ts)\n" " AND module IN (SELECT module FROM cellule\n" " GROUP BY module HAVING max(ts) > now() - interval '2 hours')\n" "GROUP BY ts, module ORDER BY 1"), sql("B", "SELECT ts AS time, 'plafond de charge (A)' AS metric,\n" " valeur * 20 AS mv\n" "FROM mesure\n" "WHERE cle = 'sensor.airtria_8_us2000c_maximum_allowed_charge_current'\n" " AND $__timeFilter(ts) ORDER BY 1")], PG, 12, 23, 12, 8, unit="mvolt", desc="Le plafond de charge est trace a l'echelle (20 mV par ampere) pour " "tenir sur le meme axe. Le 05/09 a 13:05:08 la cellule meneuse est " "passee de 3541 a 3555 mV ; cinq secondes plus tard le plafond " "tombait de 50 a 30 A. Ensuite le courant baisse, la cellule se " "detend, l'alarme repasse au vert — et le plafond, lui, reste bas.", extra={"fieldConfig": {"defaults": {"thresholds": SEUILS_CELLULE}}}), table("Amplitude de chaque cellule sur la periode", [ sql("A", "SELECT cellule AS \"Cellule\",\n" " min(mv) AS \"Minimum\", max(mv) AS \"Maximum\",\n" " max(mv) - min(mv) AS \"Amplitude\"\n" "FROM cellule\n" "WHERE module = '$module' AND $__timeFilter(ts)\n" "GROUP BY cellule ORDER BY 4 DESC", fmt="table")], PG, 0, 31, 12, 10, desc="Le classement qui designe les cellules a traiter. Sur un module " "sain les quinze amplitudes sont identiques a quelques millivolts " "pres. Une amplitude de trois a quatre fois celle des voisines " "designe une cellule qui a perdu sa capacite — et cela ne se " "rattrape pas par un reequilibrage.", overrides=[sur("Amplitude", [{"id": "custom.cellOptions", "value": {"type": "color-background"}}, {"id": "unit", "value": "mvolt"}, {"id": "thresholds", "value": seuils( ("green", None), ("#EAB839", 250), ("orange", 400), ("red", 600))}]), sur("Minimum", [{"id": "unit", "value": "mvolt"}]), sur("Maximum", [{"id": "unit", "value": "mvolt"}])]), ts("Etat de charge du parc pendant les relevés", [ sql("A", "SELECT ts AS time, valeur AS \"SOC du parc\"\n" "FROM mesure WHERE cle = 'sensor.victron_battery_soc'\n" " AND $__timeFilter(ts) ORDER BY 1"), sql("B", "SELECT ts AS time, valeur AS \"Courant du parc\"\n" "FROM mesure WHERE cle = 'sensor.victron_battery_current'\n" " AND $__timeFilter(ts) ORDER BY 1")], PG, 12, 31, 12, 10, desc="Le contexte sans lequel un ecart entre cellules ne veut rien dire : " "l'ecart grandit avec l'etat de charge, et l'excursion de la cellule " "abimee grandit avec le courant. Le 05/09, a tension de parc egale, " "la meneuse etait a 3555 mV sous 50 A et a 3496 mV sous 30 A."), ], frm="now-7d", variables=[VARIABLE_MODULE]) # ===================================================================== # 8. LA JOURNEE — le bilan condense, de minuit a maintenant # ===================================================================== # Les sept autres tableaux sont thematiques : on y va quand on sait deja quoi # chercher. Celui-ci repond a la question qu'on se pose en premier — « qu'est-ce # qui s'est passe aujourd'hui ». D'ou la fenetre `now/d` (le jour civil en # cours) et non `now-24h`, qui melangerait deux journees et rendrait le total # de production incomparable d'un jour a l'autre. # # Toutes les energies sont obtenues en INTEGRANT la puissance, jamais 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). BILAN_CLES = [ ('sensor.victron_pv_power_system', 'pv'), ('sensor.victron_consumption_power_l1', 'maison'), ('sensor.victron_grid_power_l1', 'reseau'), ('sensor.victron_evcs_ac_power', 'voiture'), ('sensor.victron_battery_power', 'batterie'), ] _bn = [n for _, n in BILAN_CLES] # ⚠️ Les bornes de l'axe viennent des DONNEES (min/max de `mesure` dans la # fenetre), pas des macros `$__timeFrom()` / `$__timeTo()`. Ce panneau rendait # sept « — » a l'ecran alors que la requete donnait les bons totaux en psql et # que Grafana ne journalisait aucune erreur : une ligne de NULL, donc un axe # vide cote serveur. La seule macro utilisee ici est `$__timeFilter(ts)`, comme # dans le panneau « Sur la periode » des vehicules, qui lui a toujours marche. # C'est aussi plus juste physiquement : on n'extrapole pas au-dela de la # derniere mesure observee. BILAN_BASE = ("""WITH bornes AS ( SELECT min(ts) AS d, max(ts) AS f FROM mesure WHERE $__timeFilter(ts) ), 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},\n" f" (array_agg(valeur ORDER BY ts DESC) FILTER (WHERE cle = '{c}'))[1] AS d_{n}" for c, n in BILAN_CLES) + """ FROM mesure WHERE $__timeFilter(ts) GROUP BY 1 ), par_minute AS ( SELECT axe.t, """ + ", ".join(f"releve.{n}, releve.d_{n}" for n in _bn) + """ FROM axe LEFT JOIN releve USING (t) ), paliers AS ( SELECT t, """ + ", ".join(f"{n}, d_{n}" for n in _bn) + ",\n" + ",\n".join(f" count(d_{n}) OVER (ORDER BY t) AS g_{n}" for n in _bn) + """ FROM par_minute ), report AS ( SELECT t, """ + ",\n".join( f" coalesce({n}, first_value(d_{n}) OVER (PARTITION BY g_{n} ORDER BY t), 0)" f" AS {n}" for n in _bn) + """ FROM paliers ) """) # Une seule ligne, six colonnes : c'est le resume que l'on lit en premier. # L'autonomie se calcule sur la maison SANS la voiture, sinon une grosse charge # solaire du vehicule gonfle artificiellement le taux. # Le bilan sort en COLONNES : le tableau rend une ligne d'en-tetes et une ligne # de valeurs, ce qui est la forme la plus lisible pour un resume. # # ⚠️ C'est un panneau `table`, PAS un `stat`. Quatre tentatives avec un `stat` # sur exactement cette requete ont affiche sept « — » alors que l'API # `/api/ds/query` montrait que le serveur lui renvoyait bien sept champs de type # number avec leurs valeurs. Ce n'etait ni la requete, ni le cache, ni le # provisioning (md5 du fichier servi et version en base verifies) : le panneau # recevait les donnees et ne les rendait pas. Ne pas repasser en `stat`. # # ⚠️ Le `GROUP BY (1 = 1)` reste necessaire — un agregat global sans GROUP BY # rendait un resultat vide cote Grafana. PostgreSQL refuse `GROUP BY true` # (non-integer constant) mais accepte cette forme, qui n'impose aucune colonne # dans le SELECT. BILAN_TOTAUX = BILAN_BASE + """SELECT round((sum(pv) / 60.0 / 1000.0)::numeric, 1) AS "Solaire produit", round((sum(maison) / 60.0 / 1000.0)::numeric, 1) AS "Maison consommee", round((sum(reseau) / 60.0 / 1000.0)::numeric, 1) AS "Reseau importe", round((sum(voiture) / 60.0 / 1000.0)::numeric, 1) AS "Voiture", round((sum(greatest(batterie, 0)) / 60.0 / 1000.0)::numeric, 1) AS "Batterie chargee", round((sum(greatest(-batterie, 0)) / 60.0 / 1000.0)::numeric, 1) AS "Batterie rendue", round((100.0 * greatest(sum(maison) - sum(reseau), 0) / nullif(sum(maison), 0))::numeric, 0) AS "Autonomie", -- Ce qui reste a venir aujourd'hui, a cote de ce qui est deja fait. Sous- -- requete independante de `report` : elle ne touche a aucun total mesure. (SELECT round(coalesce(sum(kwh), 0)::numeric, 1) FROM (""" + PREVISION_A_VENIR + """) p WHERE p.heure < date_trunc('day', now()) + interval '1 day') AS "Reste attendu" FROM report GROUP BY (1 = 1)""" BILAN_FLUX = BILAN_BASE + """SELECT t AS time, pv AS "Production solaire", maison AS "Consommation maison", voiture AS "Charge du vehicule", reseau AS "Reseau", batterie AS "Batterie (+ charge, - decharge)" FROM report ORDER BY t""" journee = dashboard( 'evcs-journee', 'La journee', "Le bilan de la journee en cours, de minuit a maintenant : ce qui a ete " "produit, ce qui a ete consomme, ce qui a ete achete, et ou c'est alle. " "Les energies sont integrees depuis les puissances mesurees.", [ table("Le bilan depuis minuit", [sql("A", BILAN_TOTAUX, fmt="table")], PG, 0, 0, 24, 4, desc="Une ligne d'en-tetes, une ligne de valeurs. L'autonomie est la part " "de la consommation de la maison qui n'est PAS venue du reseau. " "« Batterie rendue » est ce qu'elle a restitue, donc du solaire " "differe et non une consommation supplementaire. « Reste attendu » " "est la seule colonne qui ne soit pas une mesure : c'est ce que la " "derniere prevision annonce pour les heures qui restent.", overrides=[sur(n, [{"id": "unit", "value": "kwatth"}, {"id": "decimals", "value": 1}]) for n in ("Solaire produit", "Maison consommee", "Voiture", "Batterie chargee", "Batterie rendue")] + [sur("Reseau importe", [{"id": "unit", "value": "kwatth"}, {"id": "decimals", "value": 1}, {"id": "custom.cellOptions", "value": {"type": "color-text"}}, {"id": "thresholds", "value": seuils( ("green", None), ("#EAB839", 3), ("orange", 8))}]), sur("Reste attendu", [{"id": "unit", "value": "kwatth"}, {"id": "decimals", "value": 1}, {"id": "custom.cellOptions", "value": {"type": "color-text"}}, {"id": "color", "value": {"mode": "fixed", "fixedColor": "#9e9e9e"}}]), sur("Autonomie", [{"id": "unit", "value": "percent"}, {"id": "decimals", "value": 0}, {"id": "custom.cellOptions", "value": {"type": "color-background"}}, {"id": "thresholds", "value": seuils( ("red", None), ("#EAB839", 60), ("green", 85))}])]), # ⚠️ PAS de FILTRE_VUE ici : cette transformation ne garde que les colonnes # nommees par la variable $vue de « Le systeme ». Sur un tableau qui n'a pas # cette variable, elle filtre TOUT et Grafana affiche « No numeric fields # found ». Ici on veut les quatre colonnes, tout le temps. barchart("Energie heure par heure", [sql("A", energie_sql(prevision=True), fmt="table")], PG, 0, 4, 24, 9, "kwatth", "La forme de la journee : quand le solaire a couvert la maison, quand il " "a fallu acheter — et, a la suite des barres mesurees, ce que la derniere " "prevision annonce pour les heures qui restent. L'heure EN COURS n'a pas " "de barre de prevision : elle est deja mesuree pour ses minutes ecoulees.", overrides=[ # Gris clair et translucide : ce n'est pas une mesure, et le coup d'oeil # doit le dire avant la legende. sur("Encore attendu", [ {"id": "color", "value": {"mode": "fixed", "fixedColor": "#9e9e9e"}}, {"id": "custom.fillOpacity", "value": 35}])]), ts("Les flux de la journee", [sql("A", BILAN_FLUX)], PG, 0, 14, 24, 10, "watt", desc="Les cinq puissances sur le meme axe. La batterie est signee : " "au-dessus de zero elle se remplit, en dessous elle porte la maison. " "Quand « Reseau » decolle alors que la production est forte, c'est " "une fermeture du contacteur qu'il faut expliquer."), ts("Etat de charge, et ce que la nuit exigera", [ sql("A", "SELECT ts AS time, valeur AS \"SOC reel\"\n" "FROM mesure WHERE cle = 'sensor.victron_battery_soc'\n" " AND $__timeFilter(ts) ORDER BY 1"), sql("B", "SELECT ts AS time, soc_requis_pct AS \"SOC requis par le bilan\"\n" "FROM bilan WHERE $__timeFilter(ts) AND statut = 'ok' ORDER BY 1")], PG, 0, 24, 12, 9, "percent", desc="Le SOC reel contre ce que le bilan energetique reclame pour " "traverser la nuit jusqu'au croisement production/consommation. " "Quand le reel passe sous la ligne en fin de journee, le reseau sera " "necessaire avant le matin."), timeline("Reseau, source et decisions", [ sql("A", "SELECT ts AS time, source_mode AS \"Source\"\n" "FROM decision WHERE $__timeFilter(ts) ORDER BY 1", fmt="table"), sql("B", "SELECT ts AS time, etat AS \"Etat du controleur\"\n" "FROM decision WHERE $__timeFilter(ts) ORDER BY 1", fmt="table"), sql("C", "SELECT ts AS time,\n" " CASE WHEN pv_censure THEN 'production bridee'\n" " ELSE 'production libre' END AS \"Production\"\n" "FROM bilan WHERE $__timeFilter(ts) ORDER BY 1", fmt="table")], PG, 12, 24, 12, 9, desc="Trois bandes qui se lisent ensemble. « Production bridee » signale " "que le solaire n'avait plus de preneur : l'energie manquante ce " "moment-la a ete refusee, pas absente."), table("Les charges du jour", [sql("A", "SELECT to_char(debut AT TIME ZONE 'Europe/Paris', 'HH24:MI') AS \"Debut\",\n" " coalesce(to_char(fin AT TIME ZONE 'Europe/Paris', 'HH24:MI'), 'en cours') AS \"Fin\",\n" " coalesce(vehicule, '?') AS \"Vehicule\",\n" " round(coalesce(energie_compteur_kwh, energie_kwh)::numeric, 2) AS \"Energie\",\n" " round(coalesce(p_max_w, 0)::numeric) AS \"Pointe\",\n" " coalesce(source_mode, '?') AS \"Source\"\n" "FROM session_charge WHERE $__timeFilter(debut) ORDER BY debut DESC", fmt="table")], PG, 0, 33, 12, 8, desc="⚠️ L'etiquette du vehicule vient d'un selecteur manuel : elle est " "fausse s'il n'a pas ete positionne au branchement. Les deux " "voitures sont electriquement indiscernables (3 680 W contre " "3 655 W a consigne egale).", overrides=[sur("Energie", [{"id": "unit", "value": "kwatth"}]), sur("Pointe", [{"id": "unit", "value": "watt"}])]), table("Ce qui est arrive aujourd'hui", [sql("A", "SELECT to_char(ts AT TIME ZONE 'Europe/Paris', 'HH24:MI:SS') AS \"Heure\",\n" " genre AS \"Genre\", resume AS \"Evenement\"\n" "FROM evenement WHERE $__timeFilter(ts) AND genre <> 'bilan'\n" "ORDER BY ts DESC", fmt="table")], PG, 12, 33, 12, 8, desc="Le genre `bilan` est ecarte : ses bascules de production bridee " "changent plusieurs fois par heure et noieraient le reste. Elles " "sont lisibles dans la frise ci-dessus."), ], frm="now/d", jusqu="now/d") base = pathlib.Path('deploy/historisation/grafana/dashboards') for nom, d in [('journee.json', journee), ('nuit.json', nuit), ('solaire.json', solaire), ('prevision.json', prevision), ('telephone.json', telephone), ('systeme.json', systeme), ('vehicules.json', vehicules), ('batteries.json', batteries)]: (base / nom).write_text(json.dumps(d, indent=2, ensure_ascii=False) + '\n') print(f"{nom:16s} {len(d['panels'])} panneaux")