diff --git a/deploy/historisation/grafana/dashboards/batteries.json b/deploy/historisation/grafana/dashboards/batteries.json index 91ab4f0..104487b 100644 --- a/deploy/historisation/grafana/dashboards/batteries.json +++ b/deploy/historisation/grafana/dashboards/batteries.json @@ -73,6 +73,212 @@ ] }, "panels": [ + { + "type": "table", + "title": "Sante des modules — tous, d'un coup d'oeil", + "description": "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.", + "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 vivants AS (\n SELECT module FROM cellule GROUP BY module\n HAVING max(ts) > now() - interval '2 hours'),\ndernier AS (\n SELECT DISTINCT ON (module) module, ts FROM cellule\n WHERE module IN (SELECT module FROM vivants)\n ORDER BY module, ts DESC),\nmaintenant 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\n FROM cellule c JOIN dernier d ON d.module = c.module AND d.ts = c.ts\n GROUP BY c.module, d.ts),\nperiode 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)\nSELECT 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" + } + ], + "gridPos": { + "x": 0, + "y": 0, + "w": 24, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + }, + "filterable": true + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Ecart maintenant" + }, + "properties": [ + { + "id": "unit", + "value": "mvolt" + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "#EAB839", + "value": 20 + }, + { + "color": "orange", + "value": 80 + }, + { + "color": "red", + "value": 150 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Ecart max sur la periode" + }, + "properties": [ + { + "id": "unit", + "value": "mvolt" + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "#EAB839", + "value": 50 + }, + { + "color": "orange", + "value": 150 + }, + { + "color": "red", + "value": 300 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Plus haute" + }, + "properties": [ + { + "id": "unit", + "value": "mvolt" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "#EAB839", + "value": 3480 + }, + { + "color": "orange", + "value": 3520 + }, + { + "color": "red", + "value": 3550 + }, + { + "color": "dark-red", + "value": 3600 + } + ] + } + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Plus basse" + }, + "properties": [ + { + "id": "unit", + "value": "mvolt" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Courant (A)" + }, + "properties": [ + { + "id": "unit", + "value": "amp" + }, + { + "id": "decimals", + "value": 1 + } + ] + } + ] + }, + "options": { + "showHeader": true, + "cellHeight": "sm", + "footer": { + "show": false + } + } + }, { "type": "timeseries", "title": "Cellule par cellule — $module", @@ -96,7 +302,7 @@ ], "gridPos": { "x": 0, - "y": 0, + "y": 8, "w": 24, "h": 10 }, @@ -148,6 +354,59 @@ } } }, + { + "type": "timeseries", + "title": "Le module chargeait-il ? — $module", + "description": "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.", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "format": "time_series", + "rawQuery": true, + "editorMode": "code", + "rawSql": "SELECT ts AS time, 'courant du module' AS metric,\n courant_ma / 1000.0 AS a\nFROM cellule\nWHERE module = '$module' AND cellule = 1\n AND courant_ma IS NOT NULL AND $__timeFilter(ts)\nORDER BY 1" + } + ], + "gridPos": { + "x": 0, + "y": 18, + "w": 24, + "h": 5 + }, + "fieldConfig": { + "defaults": { + "unit": "amp", + "color": { + "mode": "palette-classic" + }, + "custom": { + "fillOpacity": 15, + "lineWidth": 1 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, { "type": "timeseries", "title": "Ecart entre la plus haute et la plus basse", @@ -171,7 +430,7 @@ ], "gridPos": { "x": 0, - "y": 10, + "y": 23, "w": 12, "h": 8 }, @@ -239,7 +498,7 @@ ], "gridPos": { "x": 12, - "y": 10, + "y": 23, "w": 12, "h": 8 }, @@ -308,7 +567,7 @@ ], "gridPos": { "x": 0, - "y": 18, + "y": 31, "w": 12, "h": 10 }, @@ -433,7 +692,7 @@ ], "gridPos": { "x": 12, - "y": 18, + "y": 31, "w": 12, "h": 10 }, diff --git a/tools/gen-grafana-dashboards.py b/tools/gen-grafana-dashboards.py index 3e72bb6..61e5bda 100644 --- a/tools/gen-grafana-dashboards.py +++ b/tools/gen-grafana-dashboards.py @@ -1196,13 +1196,70 @@ batteries = dashboard("evcs-batteries", "Les batteries", ( "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 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\n" + " FROM cellule c JOIN dernier d ON d.module = c.module AND d.ts = c.ts\n" + " GROUP BY c.module, d.ts),\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.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.", + 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, 0, 24, 10, unit="mvolt", + 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 " @@ -1216,6 +1273,28 @@ batteries = dashboard("evcs-batteries", "Les batteries", ( # 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. @@ -1225,7 +1304,7 @@ batteries = dashboard("evcs-batteries", "Les batteries", ( " 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, 10, 12, 8, unit="mvolt", + 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."), @@ -1242,7 +1321,7 @@ batteries = dashboard("evcs-batteries", "Les batteries", ( "FROM mesure\n" "WHERE cle = 'sensor.airtria_8_us2000c_maximum_allowed_charge_current'\n" " AND $__timeFilter(ts) ORDER BY 1")], - PG, 12, 10, 12, 8, unit="mvolt", + 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 " @@ -1257,7 +1336,7 @@ batteries = dashboard("evcs-batteries", "Les batteries", ( "FROM cellule\n" "WHERE module = '$module' AND $__timeFilter(ts)\n" "GROUP BY cellule ORDER BY 4 DESC", fmt="table")], - PG, 0, 18, 12, 10, + 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 " @@ -1279,7 +1358,7 @@ batteries = dashboard("evcs-batteries", "Les batteries", ( 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, 18, 12, 10, + 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, " diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 024cbf0..3b96a36 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -1012,15 +1012,25 @@ async function ingererCellules(client) { const cols = l[0].split(','); const iC = cols.indexOf('c1_mV'); if (iC < 0) continue; + // Le courant du module, ajoute au releve le 17/09/2026. Repere PAR SON NOM : + // les US5000 et les US2000C n'emettent pas les memes colonnes, et l'ancrer + // sur une position ferait lire la mauvaise grandeur des qu'on deplace le + // cable d'un pack a l'autre. Absent des anciens fichiers => null, donc les + // lignes deja versees gardent leur courant inconnu. + const iI = cols.indexOf('courant_mA'); const val = []; for (const ligne of l.slice(1)) { const c = ligne.split(','); if (c.length < iC + 15) continue; if (!/^[A-Z]\d{6}[A-Z]\d+$/.test(c[1])) continue; // sans code-barres, on ne devine pas + // Signe : negatif en decharge, positif en charge — c'est ce qui permet de + // filtrer « pendant la charge » sans deviner. + const ma = iI >= 0 ? Number(c[iI]) : NaN; + const courant = Number.isFinite(ma) ? Math.round(ma) : null; for (let k = 0; k < 15; k++) { const mv = Number(c[iC + k]); if (!(mv > 1000)) continue; - val.push([c[0], c[1], k + 1, mv, null, null, null, 'suivi']); + val.push([c[0], c[1], k + 1, mv, null, courant, null, 'suivi']); } } for (let i = 0; i < val.length; i += 500) {