Tableau « Les vehicules » : ce que chaque voiture encaisse, par periode

Demande du proprietaire : voir ce que la JEEP ou la VW encaisse dans une
session, et evaluer sur une periode (jour, semaine, mois), en assumant
l'imprecision liee a l'impossibilite d'identifier le vehicule au branchement.

Trois panneaux : les totaux par vehicule sur la periode, l'energie par periode
et par vehicule avec le meme pas adaptatif que les autres tableaux (heure /
jour / mois), et la liste des sessions.

L'incertitude est AFFICHEE plutot que masquee :
- un compteur « Etiquette douteuse » — sessions ou le selecteur a change en
  cours de route, ou n'etait pas renseigne ;
- une colonne « Doute » dans le tableau ;
- une serie « Non etiquete » dans le graphique ;
- l'heure de debranchement, qui confirme apres coup puisque les deux vehicules
  partent a des heures differentes.

Le commentaire du generateur consigne pourquoi aucune verification n'est
possible : electriquement les deux voitures sont indiscernables (3 680 contre
3 655 W a consigne identique de 20 A), et horairement la routine s'inverse le
week-end, donc une regle horaire serait fausse un week-end sur deux.

Ajout a la table : `energie_reseau_kwh`, la part venue du reseau, fondee sur le
`source_mode` du controleur — son intention — et non sur la puissance reseau
instantanee, laquelle peut alimenter la maison pendant que le soleil alimente
la voiture.

52/53 requetes verifiees. La seule en echec porte sur cette nouvelle colonne,
que la migration creera au prochain demarrage du service.

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-05 03:04:06 +02:00
parent e80dbf70fb
commit c00a2107b3
4 changed files with 512 additions and 2 deletions

View File

@@ -0,0 +1,389 @@
{
"templating": {
"list": []
},
"uid": "evcs-vehicules",
"title": "Les vehicules",
"description": "Ce que chaque voiture encaisse, session par session et sur la periode choisie. L'etiquette vient d'un selecteur manuel : elle peut etre fausse, et rien ne permet de la verifier — les deux vehicules sont electriquement indiscernables et leur routine s'inverse le week-end.",
"tags": [
"evcs"
],
"timezone": "Europe/Paris",
"editable": true,
"schemaVersion": 39,
"version": 1,
"refresh": "5m",
"time": {
"from": "now-7d",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"30s",
"1m",
"5m",
"15m",
"1h"
]
},
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"name": "Annotations & Alerts",
"iconColor": "rgba(0, 211, 255, 1)",
"type": "dashboard"
},
{
"name": "Evenements",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"enable": true,
"iconColor": "semi-dark-orange",
"hide": false,
"target": {
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "SELECT ts AS time, resume AS text, genre || ',' || coalesce(source,'?') AS tags FROM evenement WHERE $__timeFilter(ts) ORDER BY 1"
}
}
]
},
"panels": [
{
"type": "stat",
"title": "Sur la periode",
"description": "« Etiquette douteuse » compte les sessions ou le selecteur a change en cours de route, ou n'etait pas renseigne. C'est la mesure honnete de l'incertitude : elle ne peut pas etre levee autrement qu'en positionnant le selecteur au branchement.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "SELECT round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'VW'), 0)::numeric, 1) AS \"VW\",\n round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'JEEP'), 0)::numeric, 1) AS \"JEEP\",\n count(*) AS \"Sessions\",\n count(*) FILTER (WHERE NOT vehicule_sur OR vehicule IS NULL) AS \"Etiquette douteuse\"\nFROM session_charge WHERE $__timeFilter(debut)"
}
],
"gridPos": {
"x": 0,
"y": 0,
"w": 24,
"h": 5
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "text",
"value": null
}
]
},
"mappings": []
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "VW"
},
"properties": [
{
"id": "unit",
"value": "kwatth"
},
{
"id": "color",
"value": {
"mode": "fixed",
"fixedColor": "blue"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "JEEP"
},
"properties": [
{
"id": "unit",
"value": "kwatth"
},
{
"id": "color",
"value": {
"mode": "fixed",
"fixedColor": "green"
}
}
]
},
{
"matcher": {
"id": "byName",
"options": "Etiquette douteuse"
},
"properties": [
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 1
}
]
}
}
]
}
]
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"orientation": "horizontal",
"textMode": "auto",
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"percentChangeColorMode": "standard",
"noValue": "—",
"text": {
"valueSize": 34,
"titleSize": 12
},
"wideLayout": true
}
},
{
"type": "barchart",
"title": "Energie par periode et par vehicule",
"description": "Le pas suit la plage choisie : l'heure jusqu'a 4 jours, le jour jusqu'a 70, le mois au-dela. « Non etiquete » est ce qui a ete charge sans que le selecteur soit renseigne — a interpreter, pas a ignorer.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "WITH bornes AS (\n SELECT $__timeFrom()::timestamptz AS d, $__timeTo()::timestamptz AS f\n),\npas AS (\n SELECT CASE WHEN f - d > interval '70 days' THEN 'month'\n WHEN f - d > interval '4 days' THEN 'day'\n ELSE 'hour' END AS unite,\n CASE WHEN f - d > interval '70 days' THEN 'MM/YYYY'\n WHEN f - d > interval '4 days' THEN 'DD/MM'\n ELSE 'DD/MM HH24' END AS forme\n FROM bornes\n)\nSELECT to_char(date_trunc(pas.unite, debut), pas.forme) AS \"Periode\",\n round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'VW'), 0)::numeric, 2) AS \"VW\",\n round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'JEEP'), 0)::numeric, 2) AS \"JEEP\",\n round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule IS NULL), 0)::numeric, 2) AS \"Non etiquete\"\nFROM session_charge, pas\nWHERE $__timeFilter(debut)\nGROUP BY date_trunc(pas.unite, debut), pas.forme\nORDER BY date_trunc(pas.unite, debut)"
}
],
"gridPos": {
"x": 0,
"y": 5,
"w": 24,
"h": 9
},
"fieldConfig": {
"defaults": {
"unit": "kwatth",
"color": {
"mode": "palette-classic"
},
"custom": {
"lineWidth": 1,
"fillOpacity": 80,
"gradientMode": "none",
"axisBorderShow": false
}
},
"overrides": []
},
"options": {
"orientation": "auto",
"xTickLabelRotation": -45,
"showValue": "never",
"stacking": "none",
"groupWidth": 0.7,
"barWidth": 0.9,
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
}
},
{
"type": "table",
"title": "Les sessions",
"description": "« Debranche » confirme le vehicule apres coup : les deux partent a des heures differentes. « dont reseau » se fonde sur le mode source declare par le controleur — son intention — et non sur la puissance reseau instantanee, qui peut alimenter la maison pendant que le soleil alimente la voiture.",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "evcs-pg"
},
"format": "table",
"rawQuery": true,
"editorMode": "code",
"rawSql": "SELECT to_char(debut, 'Dy DD/MM HH24:MI') AS \"Debut\",\n coalesce(vehicule, '?') AS \"Vehicule\",\n CASE WHEN vehicule_sur THEN '' ELSE 'selecteur change' END AS \"Doute\",\n round((extract(epoch FROM fin - debut) / 60)::numeric) AS \"Minutes\",\n round(energie_kwh::numeric, 2) AS \"kWh\",\n round(energie_reseau_kwh::numeric, 2) AS \"dont reseau\",\n round(p_max_w::numeric) AS \"P max\",\n consigne_max_a AS \"Consigne max\",\n source_mode AS \"Source au depart\",\n to_char(debranche_a, 'Dy HH24:MI') AS \"Debranche\"\nFROM session_charge WHERE $__timeFilter(debut) ORDER BY debut DESC"
}
],
"gridPos": {
"x": 0,
"y": 14,
"w": 24,
"h": 11
},
"fieldConfig": {
"defaults": {
"custom": {
"align": "left",
"cellOptions": {
"type": "auto"
},
"filterable": true
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Vehicule"
},
"properties": [
{
"id": "custom.width",
"value": 100
},
{
"id": "custom.cellOptions",
"value": {
"type": "color-text"
}
},
{
"id": "mappings",
"value": [
{
"type": "value",
"options": {
"VW": {
"color": "blue",
"index": 0
},
"JEEP": {
"color": "green",
"index": 1
},
"?": {
"color": "orange",
"index": 2
}
}
}
]
}
]
},
{
"matcher": {
"id": "byName",
"options": "Doute"
},
"properties": [
{
"id": "custom.width",
"value": 150
}
]
},
{
"matcher": {
"id": "byName",
"options": "kWh"
},
"properties": [
{
"id": "unit",
"value": "kwatth"
}
]
},
{
"matcher": {
"id": "byName",
"options": "dont reseau"
},
"properties": [
{
"id": "unit",
"value": "kwatth"
}
]
},
{
"matcher": {
"id": "byName",
"options": "P max"
},
"properties": [
{
"id": "unit",
"value": "watt"
}
]
}
]
},
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {
"show": false
}
}
}
]
}

View File

@@ -140,6 +140,10 @@ CREATE TABLE session_charge (
vehicule text, -- valeur du selecteur PENDANT la session
vehicule_sur boolean NOT NULL DEFAULT true, -- le selecteur n'a pas change
energie_kwh double precision NOT NULL DEFAULT 0,
-- Part venue du reseau : on se fie au `source_mode` du controleur, son
-- INTENTION, et non a la puissance reseau instantanee — laquelle peut
-- alimenter la maison pendant que le soleil alimente la voiture.
energie_reseau_kwh double precision NOT NULL DEFAULT 0,
p_max_w double precision,
courant_max_a double precision,
consigne_max_a double precision,

View File

@@ -950,10 +950,119 @@ systeme = dashboard("evcs-systeme", "Le systeme", (
transformations=FILTRE_VUE),
])
# =====================================================================
# 6. LES VEHICULES — ce que chaque voiture encaisse, et sur quelle periode
# =====================================================================
# ⚠️ LIMITE STRUCTURELLE, a garder en tete a chaque lecture de ce tableau.
# L'etiquette du vehicule vient d'un SELECTEUR MANUEL. Elle est fiable quand
# le proprietaire l'a positionnee au branchement, et fausse s'il l'a oubliee ou
# si un redemarrage de Home Assistant l'a annulee (constate le 04/09 a 19:44).
#
# Il n'existe aucun moyen de la verifier :
# - electriquement, les deux voitures sont indiscernables. Mesure du 05/09 a
# consigne identique de 20 A : la VW accepte 3 680 W, la JEEP 3 655 W,
# soit 0,1 A d'ecart. Meme chargeur embarque de 3,7 kW, meme plateau.
# - horairement, la routine s'inverse le week-end : en semaine la JEEP charge
# le midi et la VW la nuit, mais le samedi la JEEP charge la nuit et la VW
# en journee. Une regle horaire serait fausse un week-end sur deux.
#
# D'ou la colonne « Doute » du tableau et le compteur d'etiquettes douteuses :
# on affiche l'incertitude au lieu de la masquer.
SESSIONS_PERIODE = """WITH bornes AS (
SELECT $__timeFrom()::timestamptz AS d, $__timeTo()::timestamptz AS f
),
pas AS (
SELECT CASE WHEN f - d > interval '70 days' THEN 'month'
WHEN f - d > interval '4 days' THEN 'day'
ELSE 'hour' END AS unite,
CASE WHEN f - d > interval '70 days' THEN 'MM/YYYY'
WHEN f - d > interval '4 days' THEN 'DD/MM'
ELSE 'DD/MM HH24' END AS forme
FROM bornes
)
SELECT to_char(date_trunc(pas.unite, debut), pas.forme) AS "Periode",
round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'VW'), 0)::numeric, 2) AS "VW",
round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'JEEP'), 0)::numeric, 2) AS "JEEP",
round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule IS NULL), 0)::numeric, 2) AS "Non etiquete"
FROM session_charge, pas
WHERE $__timeFilter(debut)
GROUP BY date_trunc(pas.unite, debut), pas.forme
ORDER BY date_trunc(pas.unite, debut)"""
vehicules = dashboard("evcs-vehicules", "Les vehicules", (
"Ce que chaque voiture encaisse, session par session et sur la periode "
"choisie. L'etiquette vient d'un selecteur manuel : elle peut etre fausse, "
"et rien ne permet de la verifier — les deux vehicules sont "
"electriquement indiscernables et leur routine s'inverse le week-end."),
frm="now-7d", refresh="5m", panels=[
stat("Sur la periode", [
sql("A",
"SELECT round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'VW'), 0)::numeric, 1) AS \"VW\",\n"
" round(coalesce(sum(energie_kwh) FILTER (WHERE vehicule = 'JEEP'), 0)::numeric, 1) AS \"JEEP\",\n"
" count(*) AS \"Sessions\",\n"
" count(*) FILTER (WHERE NOT vehicule_sur OR vehicule IS NULL) AS \"Etiquette douteuse\"\n"
"FROM session_charge WHERE $__timeFilter(debut)", fmt="table")],
PG, 0, 0, 24, 5,
"« Etiquette douteuse » compte les sessions ou le selecteur a change en "
"cours de route, ou n'etait pas renseigne. C'est la mesure honnete de "
"l'incertitude : elle ne peut pas etre levee autrement qu'en positionnant "
"le selecteur au branchement.",
taille={"valueSize": 34, "titleSize": 12},
overrides=[
sur("VW", [{"id": "unit", "value": "kwatth"},
{"id": "color", "value": {"mode": "fixed", "fixedColor": "blue"}}]),
sur("JEEP", [{"id": "unit", "value": "kwatth"},
{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}]),
sur("Etiquette douteuse", [{"id": "thresholds", "value": seuils(
("green", None), ("orange", 1))}])]),
barchart("Energie par periode et par vehicule",
[sql("A", SESSIONS_PERIODE, fmt="table")],
PG, 0, 5, 24, 9, "kwatth",
"Le pas suit la plage choisie : l'heure jusqu'a 4 jours, le jour jusqu'a "
"70, le mois au-dela. « Non etiquete » est ce qui a ete charge sans que le "
"selecteur soit renseigne — a interpreter, pas a ignorer."),
table("Les sessions", [
sql("A",
"SELECT to_char(debut, 'Dy DD/MM HH24:MI') AS \"Debut\",\n"
" coalesce(vehicule, '?') AS \"Vehicule\",\n"
" CASE WHEN vehicule_sur THEN '' ELSE 'selecteur change' END AS \"Doute\",\n"
" round((extract(epoch FROM fin - debut) / 60)::numeric) AS \"Minutes\",\n"
" round(energie_kwh::numeric, 2) AS \"kWh\",\n"
" round(energie_reseau_kwh::numeric, 2) AS \"dont reseau\",\n"
" round(p_max_w::numeric) AS \"P max\",\n"
" consigne_max_a AS \"Consigne max\",\n"
" source_mode AS \"Source au depart\",\n"
" to_char(debranche_a, 'Dy HH24:MI') AS \"Debranche\"\n"
"FROM session_charge WHERE $__timeFilter(debut) ORDER BY debut DESC", fmt="table")],
PG, 0, 14, 24, 11,
"« Debranche » confirme le vehicule apres coup : les deux partent a des "
"heures differentes. « dont reseau » se fonde sur le mode source declare "
"par le controleur — son intention — et non sur la puissance reseau "
"instantanee, qui peut alimenter la maison pendant que le soleil alimente "
"la voiture.",
overrides=[
sur("Vehicule", [{"id": "custom.width", "value": 100},
{"id": "custom.cellOptions", "value": {"type": "color-text"}},
{"id": "mappings", "value": [{"type": "value", "options": {
"VW": {"color": "blue", "index": 0},
"JEEP": {"color": "green", "index": 1},
"?": {"color": "orange", "index": 2}}}]}]),
sur("Doute", [{"id": "custom.width", "value": 150}]),
sur("kWh", [{"id": "unit", "value": "kwatth"}]),
sur("dont reseau", [{"id": "unit", "value": "kwatth"}]),
sur("P max", [{"id": "unit", "value": "watt"}])]),
])
base = pathlib.Path('deploy/historisation/grafana/dashboards')
for nom, d in [('nuit.json', nuit), ('solaire.json', solaire),
('prevision.json', prevision),
('telephone.json', telephone),
('systeme.json', systeme)]:
('systeme.json', systeme),
('vehicules.json', vehicules)]:
(base / nom).write_text(json.dumps(d, indent=2, ensure_ascii=False) + '\n')
print(f"{nom:16s} {len(d['panels'])} panneaux")

View File

@@ -349,6 +349,12 @@ async function migrer(client) {
debranche_a timestamptz,
brut jsonb
)`);
// Part venue du reseau. On se fie au `source_mode` du controleur — son
// INTENTION, sans ambiguite — et non a la puissance reseau instantanee, qui
// peut alimenter la maison pendant que le soleil alimente la voiture.
await client.query(
'ALTER TABLE session_charge ADD COLUMN IF NOT EXISTS energie_reseau_kwh '
+ 'double precision NOT NULL DEFAULT 0');
await client.query(
'CREATE INDEX IF NOT EXISTS session_charge_debut_idx ON session_charge (debut DESC)');
// Le role de lecture doit voir la nouvelle table. S'il n'existe pas encore,
@@ -413,6 +419,7 @@ async function ecrireSession(sql, client, st) {
`UPDATE session_charge SET
fin = $2,
energie_kwh = energie_kwh + $3 * $4 / 3600000.0,
energie_reseau_kwh = energie_reseau_kwh + $8 * $3 * $4 / 3600000.0,
p_max_w = GREATEST(coalesce(p_max_w, 0), $3),
courant_max_a = GREATEST(coalesce(courant_max_a, 0), coalesce($5, 0)),
consigne_max_a = GREATEST(coalesce(consigne_max_a, 0), coalesce($6, 0)),
@@ -421,7 +428,8 @@ async function ecrireSession(sql, client, st) {
WHERE debut = $1`,
[ouverte.debut, maintenant, p, dt,
num(g('sensor.victron_evcs_current')?.state),
num(g('sensor.victron_evcs_set_current')?.state), vehicule]);
num(g('sensor.victron_evcs_set_current')?.state), vehicule,
txt(g('input_text.victron_evcs_source_mode')?.state).startsWith('Réseau') ? 1 : 0]);
n++;
}
} else if (ouverte) {