Le systeme : replique locale de la vue generale VRM
La console VRM est hebergee dans le cloud Victron et disparait quand une coupure d'orage emporte Internet. Meme vision, lue sur le reseau local : reseau, charges, batterie, borne, production, detail par tracker, les flux dans le temps et l'energie heure par heure. Tout dans Grafana, rien dans Home Assistant — et pour une raison trouvee en verifiant si son tableau Energie pouvait servir : gx_device_pv_energy et les compteurs d'energie batterie du GX se remettent a zero sans le signaler. state_class total sans last_reset, donc HA n'auto-detecte pas la remise a zero et a enregistre -373,35 kWh sur l'heure de 07:00. Les energies sont donc obtenues en INTEGRANT LA PUISSANCE, methode qui ignore le probleme. Trois versions du calcul avant la bonne : somme ponderee (laissait des trous, une grandeur stable n'etant jamais reecrite), moyenne des minutes presentes (sur-comptait : 5,13 kWh a 01 h pour 22 minutes observees), puis axe des minutes complet + report + avg x count/60 = energie reellement accumulee, correcte y compris pour l'heure en cours. Recoupement avec la capture VRM du proprietaire : 8,07 kWh de reseau contre "Depuis reseau 9,0 kWh", l'ecart valant exactement les treize minutes entre l'ouverture des heures creuses et le demarrage du service d'historisation. 45/45 requetes verifiees contre les bases reelles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
@@ -597,9 +597,251 @@ telephone = dashboard("evcs-telephone", "Sur le telephone", (
|
||||
overrides=[sur("Quand", [{"id": "custom.width", "value": 110}])]),
|
||||
])
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 5. LE SYSTEME — replique locale de la vue generale VRM
|
||||
# =====================================================================
|
||||
# Raison d'etre : la console VRM est hebergee dans le cloud Victron. Elle
|
||||
# disparait exactement quand on en a le plus besoin — une coupure d'orage sur
|
||||
# ce site emporte souvent Internet avec elle. Tout ce qui suit est lu sur le
|
||||
# reseau local, sans exception.
|
||||
#
|
||||
# Choix de methode : les energies horaires sont obtenues en INTEGRANT LA
|
||||
# PUISSANCE, et non en lisant les compteurs kWh de Victron. Verifie le
|
||||
# 04/09 : `gx_device_pv_energy` et les compteurs d'energie batterie du GX se
|
||||
# remettent a zero sans le signaler, et Home Assistant enregistre alors une
|
||||
# variation de -373 kWh sur une heure. Integrer la puissance ignore
|
||||
# completement ce probleme.
|
||||
|
||||
def flux_dernier(refid, mesure, entite, libelle, champ="value"):
|
||||
"""Derniere valeur connue d'une entite, renommee pour l'affichage."""
|
||||
# Fenetre de 7 jours et non de quelques minutes : l'integration InfluxDB
|
||||
# de Home Assistant n'ecrit QUE sur changement d'etat. Une grandeur stable
|
||||
# — la borne a 0 A, voiture debranchee — n'a donc aucun point recent, et
|
||||
# une fenetre courte affiche une case vide la ou la valeur est simplement
|
||||
# constante. `last()` reste peu couteux sur une fenetre large.
|
||||
return flux(refid,
|
||||
f'from(bucket: "evcs")\n'
|
||||
f' |> range(start: -7d)\n'
|
||||
f' |> filter(fn: (r) => r._measurement == "{mesure}"'
|
||||
f' and r.entity_id == "{entite}" and r._field == "{champ}")\n'
|
||||
f' |> last()\n'
|
||||
f' |> keep(columns: ["_time", "_value"])\n'
|
||||
f' |> rename(columns: {{_value: "{libelle}"}})')
|
||||
|
||||
# Energie horaire par integration ponderee par la duree, plafonnee a 300 s
|
||||
# pour qu'un arret du service n'extrapole pas une valeur perimee.
|
||||
ENERGIE_CLES = [
|
||||
('sensor.victron_pv_power_system', 'Solaire'),
|
||||
('sensor.victron_grid_power_l1', 'Reseau'),
|
||||
('sensor.victron_consumption_power_l1', 'Maison'),
|
||||
('sensor.victron_evcs_ac_power', 'Voiture'),
|
||||
]
|
||||
|
||||
# Energie horaire = moyenne de la puissance sur l'heure, en kWh.
|
||||
#
|
||||
# Deux pieges, tous deux rencontres le 04/09 avant d'arriver a cette version :
|
||||
#
|
||||
# 1. Somme des echantillons ponderee par leur intervalle : laisse des TROUS.
|
||||
# « Solaire » n'existait qu'a 00 h, 07 h, 08 h et 09 h, parce qu'une
|
||||
# grandeur qui ne change pas n'est jamais reecrite — la production nulle
|
||||
# de la nuit ne produit qu'UNE ligne, et les heures sans ligne sortaient
|
||||
# vides au lieu de sortir zero.
|
||||
#
|
||||
# 2. Moyenne des minutes PRESENTES : sur-compte. L'heure 01 annoncait
|
||||
# 5,13 kWh de reseau alors que la fenetre heures creuses ne s'ouvre qu'a
|
||||
# 01:24 — la moyenne portait sur 36 minutes et les comptait comme 60.
|
||||
#
|
||||
# D'ou l'axe des minutes COMPLET par `generate_series`, la valeur reportee
|
||||
# depuis le dernier point connu, puis la moyenne. Chaque heure pese alors ses
|
||||
# soixante minutes, qu'il y ait eu une mesure ou non.
|
||||
ENERGIE_SQL = """WITH axe AS (
|
||||
SELECT generate_series(date_trunc('minute', $__timeFrom()::timestamptz),
|
||||
date_trunc('minute', $__timeTo()::timestamptz),
|
||||
interval '1 minute') AS t
|
||||
),
|
||||
releve AS (
|
||||
SELECT date_trunc('minute', ts) AS t,
|
||||
""" + ",\n".join(
|
||||
f" avg(valeur) FILTER (WHERE cle = '{c}') AS {n}" for c, n in ENERGIE_CLES) + """
|
||||
FROM mesure WHERE $__timeFilter(ts) GROUP BY 1
|
||||
),
|
||||
par_minute AS (
|
||||
SELECT axe.t, """ + ", ".join(f"releve.{n}" for _, n in ENERGIE_CLES) + """
|
||||
FROM axe LEFT JOIN releve USING (t)
|
||||
),
|
||||
paliers AS (
|
||||
SELECT t, """ + ", ".join(n for _, n in ENERGIE_CLES) + ",\n" + ",\n".join(
|
||||
f" count({n}) OVER (ORDER BY t) AS g_{n}" for _, n in ENERGIE_CLES) + """
|
||||
FROM par_minute
|
||||
),
|
||||
report AS (
|
||||
SELECT t,
|
||||
""" + ",\n".join(
|
||||
f" first_value({n}) OVER (PARTITION BY g_{n} ORDER BY t) AS {n}"
|
||||
for _, n in ENERGIE_CLES) + """
|
||||
FROM paliers
|
||||
)
|
||||
SELECT to_char(t, 'HH24') || ' h' AS "Heure",
|
||||
""" + ",\n".join(
|
||||
# Energie REELLEMENT accumulee : puissance moyenne x duree observee.
|
||||
# Pas la moyenne seule, qui comptait 22 minutes comme 60 et annoncait
|
||||
# 5,13 kWh de reseau a 01 h le 04/09 — le service n'avait demarre qu'a
|
||||
# 01:37. Pas non plus un rejet des heures incompletes, qui laissait
|
||||
# toujours vide l'heure EN COURS, celle qu'on regarde le plus.
|
||||
# Contrepartie assumee : une heure passee amputee par un arret du service
|
||||
# sous-estime — mais un tel arret est journalise et apparait en marqueur
|
||||
# sur le graphique.
|
||||
f' round((avg({n}) * count({n}) / 60.0 / 1000.0)::numeric, 2) AS "{n}"'
|
||||
for _, n in ENERGIE_CLES) + """
|
||||
FROM report
|
||||
GROUP BY date_trunc('hour', t), to_char(t, 'HH24')
|
||||
HAVING """ + " OR ".join(f"count({n}) > 0" for _, n in ENERGIE_CLES) + """
|
||||
ORDER BY date_trunc('hour', t)"""
|
||||
|
||||
# Detail par tracker. Les quatre entrees du RS 450/200 et le MPPT1 ne suivent
|
||||
# pas la meme convention de nommage : deux branches, reunies puis pivotees.
|
||||
TRACKERS_FLUX = '''import "strings"
|
||||
|
||||
base = from(bucket: "evcs")
|
||||
|> range(start: -7d)
|
||||
|> filter(fn: (r) => r._field == "value")
|
||||
|> filter(fn: (r) => r._measurement == "V" or r._measurement == "A" or r._measurement == "W")
|
||||
|> last()
|
||||
|
||||
rs = base
|
||||
|> filter(fn: (r) => strings.containsStr(v: r.entity_id, substr: "pv_tracker_solar_"))
|
||||
|> map(fn: (r) => ({ r with tracker:
|
||||
if strings.containsStr(v: r.entity_id, substr: "solar_1") then "Sud 1 (10 pan.)"
|
||||
else if strings.containsStr(v: r.entity_id, substr: "solar_2") then "Sud 2 (10 pan.)"
|
||||
else if strings.containsStr(v: r.entity_id, substr: "solar_3") then "Sud 3 (10 pan.)"
|
||||
else "Ouest (9 pan.)" }))
|
||||
|
||||
se = base
|
||||
|> filter(fn: (r) => r.entity_id == "airtria_mppt1_pv_bus_voltage"
|
||||
or r.entity_id == "airtria_mppt1_pv_current"
|
||||
or r.entity_id == "airtria_mppt1_pv_yield_power")
|
||||
|> map(fn: (r) => ({ r with tracker: "Sud-est (10 pan.)" }))
|
||||
|
||||
union(tables: [rs, se])
|
||||
|> keep(columns: ["tracker", "_measurement", "_value"])
|
||||
|> pivot(rowKey: ["tracker"], columnKey: ["_measurement"], valueColumn: "_value")
|
||||
|> sort(columns: ["tracker"])'''
|
||||
|
||||
def col(nom, unite, largeur=None, dec=0):
|
||||
props = [{"id": "unit", "value": unite}, {"id": "decimals", "value": dec}]
|
||||
if largeur:
|
||||
props.append({"id": "custom.width", "value": largeur})
|
||||
return sur(nom, props)
|
||||
|
||||
systeme = dashboard("evcs-systeme", "Le systeme", (
|
||||
"Replique locale de la vue generale VRM. La console Victron est hebergee "
|
||||
"dans le cloud : elle disparait quand une coupure d'orage emporte "
|
||||
"Internet. Tout ce qui est affiche ici est lu sur le reseau local."),
|
||||
frm="now/d", refresh="30s", panels=[
|
||||
|
||||
stat("Reseau", [
|
||||
flux_dernier("A", "W", "victron_grid_power_l1", "Puissance"),
|
||||
flux_dernier("B", "V", "airtria_mpii_v1_input_voltage_l1", "Tension"),
|
||||
flux_dernier("C", "A", "gx_device_grid_current_l1", "Courant")],
|
||||
IN, 0, 0, 6, 5,
|
||||
"Positif : le reseau alimente la maison. Le contacteur EDF est pilote par "
|
||||
"le workflow d'hysteresis, pas depuis ici.",
|
||||
overrides=[col("Puissance", "watt"), col("Tension", "volt"), col("Courant", "amp", dec=1)]),
|
||||
|
||||
stat("Charges CA", [
|
||||
flux_dernier("A", "W", "victron_consumption_power_l1", "Consommation"),
|
||||
flux_dernier("B", "Hz", "airtria_mpii_v1_output_frequency_l1", "Frequence")],
|
||||
IN, 6, 0, 5, 5,
|
||||
"Ce que la maison consomme, voiture comprise.",
|
||||
overrides=[col("Consommation", "watt"), col("Frequence", "hertz", dec=1)]),
|
||||
|
||||
stat("Batterie", [
|
||||
flux_dernier("A", "%", "victron_battery_soc", "Charge"),
|
||||
flux_dernier("B", "W", "victron_battery_power", "Puissance"),
|
||||
flux_dernier("C", "V", "victron_battery_voltage", "Tension"),
|
||||
flux_dernier("D", "A", "victron_battery_current", "Courant"),
|
||||
flux_dernier("E", "°C", "victron_battery_temperature", "Temperature")],
|
||||
IN, 11, 0, 8, 5,
|
||||
"Puissance positive : la batterie se charge. Sept modules actifs sur huit, "
|
||||
"un pack bloquant la charge vers 94 %.",
|
||||
overrides=[
|
||||
sur("Charge", [{"id": "unit", "value": "percent"}, {"id": "decimals", "value": 0},
|
||||
{"id": "thresholds", "value": seuils(
|
||||
("red", None), ("orange", 25), ("yellow", 40), ("green", 60))}]),
|
||||
col("Puissance", "watt"), col("Tension", "volt", dec=2),
|
||||
col("Courant", "amp", dec=1), col("Temperature", "celsius")]),
|
||||
|
||||
stat("Chargeur VE", [
|
||||
flux_dernier("A", "W", "victron_evcs_ac_power", "Puissance"),
|
||||
flux_dernier("B", "A", "victron_evcs_current", "Courant"),
|
||||
flux_dernier("C", "A", "victron_evcs_set_current", "Consigne")],
|
||||
IN, 19, 0, 5, 5,
|
||||
"Consigne = ce que le controleur demande a la borne, reecrit toutes les "
|
||||
"30 s. Courant = ce que la voiture prend reellement.",
|
||||
overrides=[col("Puissance", "watt"), col("Courant", "amp", dec=1),
|
||||
col("Consigne", "amp", dec=0)]),
|
||||
|
||||
stat("Chargeur PV", [
|
||||
flux_dernier("A", "W", "victron_pv_power_system", "Production"),
|
||||
flux_dernier("B", "W", "victron_pv_power_10m_avg", "Moyenne 10 min")],
|
||||
IN, 0, 5, 6, 8,
|
||||
"Rappel qui vaut pour toute lecture de cette valeur : sans injection, la "
|
||||
"production MESUREE vaut min(ressource, consommation + absorption "
|
||||
"batterie). Basse ne veut pas dire mauvais temps.",
|
||||
overrides=[col("Production", "watt"), col("Moyenne 10 min", "watt")]),
|
||||
|
||||
dict(table("Detail par tracker", [flux("A", TRACKERS_FLUX)],
|
||||
IN, 6, 5, 18, 8,
|
||||
"Les 52 panneaux en quatre plans : sud-est 40 degres, trois chaines sud "
|
||||
"35 degres, ouest 45 degres. Le suiveur de trois panneaux ne publie pas "
|
||||
"de mode de fonctionnement et n'apparait donc pas ici.",
|
||||
overrides=[sur("tracker", [{"id": "displayName", "value": "Tracker"},
|
||||
{"id": "custom.width", "value": 200}]),
|
||||
sur("V", [{"id": "displayName", "value": "Tension"},
|
||||
{"id": "unit", "value": "volt"}, {"id": "decimals", "value": 0}]),
|
||||
sur("A", [{"id": "displayName", "value": "Courant"},
|
||||
{"id": "unit", "value": "amp"}, {"id": "decimals", "value": 1}]),
|
||||
sur("W", [{"id": "displayName", "value": "Puissance"},
|
||||
{"id": "unit", "value": "watt"}, {"id": "decimals", "value": 0},
|
||||
{"id": "custom.cellOptions",
|
||||
"value": {"type": "gauge", "mode": "gradient"}},
|
||||
{"id": "max", "value": 3100}])]),
|
||||
# `pivot` rend les colonnes par ordre alphabetique (A, V, W). On retablit
|
||||
# l'ordre de lecture : tension, courant, puissance.
|
||||
transformations=[{"id": "organize", "options": {
|
||||
"indexByName": {"tracker": 0, "V": 1, "A": 2, "W": 3}}}]),
|
||||
|
||||
ts("Les flux", [
|
||||
sql_grandeurs("A", [
|
||||
('sensor.victron_pv_power_system', 'Solaire'),
|
||||
('sensor.victron_grid_power_l1', 'Reseau'),
|
||||
('sensor.victron_battery_power', 'Batterie'),
|
||||
('sensor.victron_consumption_power_l1', 'Maison'),
|
||||
('sensor.victron_evcs_ac_power', 'Voiture')])],
|
||||
PG, 0, 13, 24, 9, "watt",
|
||||
"Batterie positive : elle se charge. Reseau positif : il alimente. "
|
||||
"Aux instants ou tout est a zero sauf la maison, c'est la batterie qui "
|
||||
"porte tout.",
|
||||
extra={"overrides": [
|
||||
sur("Solaire", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "yellow"}}]),
|
||||
sur("Reseau", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}]),
|
||||
sur("Batterie", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "blue"}}]),
|
||||
sur("Maison", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "text"}},
|
||||
{"id": "custom.fillOpacity", "value": 0}]),
|
||||
sur("Voiture", [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}])]}),
|
||||
|
||||
barchart("Energie heure par heure", [sql("A", ENERGIE_SQL, fmt="table")],
|
||||
PG, 0, 22, 24, 9, "kwatth",
|
||||
"Obtenue en integrant la puissance, PAS en lisant les compteurs kWh du "
|
||||
"GX : ceux-ci se remettent a zero sans le signaler (verifie le 04/09, "
|
||||
"-373 kWh enregistres sur une heure cote Home Assistant)."),
|
||||
])
|
||||
|
||||
base = pathlib.Path('deploy/historisation/grafana/dashboards')
|
||||
for nom, d in [('nuit.json', nuit), ('solaire.json', solaire),
|
||||
('prevision.json', prevision),
|
||||
('telephone.json', telephone)]:
|
||||
('telephone.json', telephone),
|
||||
('systeme.json', systeme)]:
|
||||
(base / nom).write_text(json.dumps(d, indent=2, ensure_ascii=False) + '\n')
|
||||
print(f"{nom:16s} {len(d['panels'])} panneaux")
|
||||
|
||||
Reference in New Issue
Block a user