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:
Tallagrand Thierry
2026-09-04 09:44:29 +02:00
parent ea9248e949
commit 5fa4edf293
4 changed files with 1370 additions and 1 deletions

View File

@@ -3567,3 +3567,72 @@ savait de la nuit.
---
*Document mis a jour le : 04/09/2026 (~09:00) — trois defauts de grandeur corriges dans les tableaux, 19/19*
## 46. Session du 04/09/2026 — « Le systeme » : replique locale de la vue VRM
### Pourquoi
La console VRM est hebergee dans le cloud Victron. Elle disparait exactement
quand on en a le plus besoin : une coupure d'orage sur ce site emporte souvent
Internet. Demande du proprietaire : la meme vision, en local.
### Ou, et pourquoi pas ailleurs
Tout dans **Grafana**, rien dans Home Assistant. Le tableau Energie de HA
etait le candidat naturel pour la moitie basse (barres horaires) — il est
declare mais **entierement vide** (aucune source, aucun appareil). En verifiant
s'il pouvait servir, defaut majeur trouve :
| compteur | etat |
|---|---|
| `gx_device_pv_energy` | **RESET non signale** : −373,35 kWh enregistres sur l'heure de 07:00 |
| `gx_device_dc_battery_charge/discharge_energy` | **RESET** : −134,7 et −142,6 kWh |
| `airtria_mppt1_total_yield`, `mppt_450_200_..._total_yield`, `v1_total_yield` | sains, monotones |
| `airtria_mpii_v1_energy_from_ac_in_1_to_*` et retours | sains, monotones |
`state_class: total` sans `last_reset` : Home Assistant n'auto-detecte PAS la
remise a zero (c'est le comportement de `total_increasing`). Chaque redemarrage
de HA injecterait donc un trou geant dans les barres du tableau Energie.
=> Les energies du tableau Grafana sont obtenues **en integrant la puissance**,
methode immunisee contre ce probleme. Aucune ecriture dans HA.
### Trois versions du calcul d'energie horaire avant d'arriver a la bonne
1. **Somme ponderee par l'intervalle** — laisse des TROUS : une grandeur qui ne
change pas n'est jamais reecrite, donc la production nulle de la nuit ne
produit qu'UNE ligne et les heures sans ligne sortaient vides au lieu de
zero.
2. **Moyenne des minutes presentes** — SUR-COMPTE : l'heure 01 annoncait
5,13 kWh de reseau alors que le service n'avait demarre qu'a 01:37 —
22 minutes comptees comme 60.
3. **Retenu** : axe des minutes complet (`generate_series`), report de la
derniere valeur connue, puis `avg x count / 60` = energie REELLEMENT
accumulee. Correct pour une heure pleine ET pour l'heure en cours, qu'un
simple rejet des heures incompletes laissait toujours vide — or c'est celle
qu'on regarde le plus.
### Verification par recoupement avec la capture VRM
Total reseau du jour : **8,07 kWh** cote Grafana contre « Depuis reseau
9,0 kWh » cote VRM au meme moment. L'ecart de 0,9 kWh correspond exactement aux
treize minutes entre l'ouverture des heures creuses (01:24) et le demarrage du
service d'historisation (01:37). Detail par tracker recoupe aussi : la chaine
que j'ai etiquetee « Ouest » produit 132 W a 9 h contre 173-183 W pour les trois
chaines sud — et VRM montre bien Solar 4 comme la plus faible des quatre.
### Pieges InfluxDB notes au passage
- L'integration `influxdb` de HA n'ecrit **que sur changement d'etat**. Une
fenetre `range(start: -30m)` laisse donc vides les cases dont la valeur est
stable — la borne a 0 A, voiture debranchee. Fenetre portee a **7 jours**,
`last()` reste peu couteux.
- `pivot` rend les colonnes par ordre alphabetique (A, V, W) : une
transformation `organize` retablit tension / courant / puissance.
### Ce qui reste ouvert
- Le tableau Energie de HA pourrait etre configure avec les compteurs SAINS
(trois `*_total_yield` en sources solaires, quatre compteurs MultiPlus pour
le reseau). Il manquerait la batterie, faute de compteur monotone — a creer
par integration de Riemann sur `sensor.victron_battery_power` si on le veut.
Non fait : le tableau Grafana couvre le besoin sans rien ecrire dans HA.
- Le suiveur de trois panneaux (« V1 », visible dans VRM a 84 V / 0,4 A / 33 W)
n'est pas dans les globs d'entites de `ha-influxdb.yaml` : il manque au
tableau des trackers. A ajouter si le detail compte.
---
*Document mis a jour le : 04/09/2026 (~10:00) — tableau « Le systeme », 45/45 requetes verifiees*

View File

@@ -226,6 +226,18 @@ Mot de passe perdu :
mode déclaré par chacun des sept chargeurs.
- **Prévision contre réel** — le biais de forme de la prévision, question
ouverte qui demande des semaines de données.
- **Le système** — réplique locale de la vue générale VRM : réseau, charges,
batterie, borne, production, **détail par tracker**, les flux dans le temps
et l'énergie heure par heure. Existe parce que la console Victron est
hébergée dans le cloud et disparaît quand une coupure d'orage emporte
Internet. Tout y est lu sur le réseau local.
⚠️ Les énergies sont obtenues **en intégrant la puissance**, jamais en lisant
les compteurs kWh du GX : `gx_device_pv_energy` et les compteurs d'énergie
batterie se remettent à zéro sans le signaler, et Home Assistant enregistre
alors une variation de −373 kWh sur une heure (vérifié le 04/09). Les
compteurs `*_total_yield` des chargeurs et ceux du MultiPlus, eux, sont
monotones et sains.
- **Sur le téléphone** — une seule colonne, six panneaux, et la réponse en
premier. À épingler sur l'écran d'accueil de l'iPhone :
`http://10.10.10.22:3001/d/evcs-telephone?kiosk` (Safari → Partager → Sur

File diff suppressed because it is too large Load Diff

View File

@@ -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")