Files
EVCS/tools/guetter-plafond-bms.mjs
Tallagrand Thierry f762616893 Les tensions de cellule entrent dans la base, avec leur decalage d'horloge
Nouvelle table cellule, indexee par CODE-BARRES et non par adresse :
chaque chaine renumerote ses modules a partir de 1, il existe donc
deux « module 1 » dans ce parc.

L'importeur lit les exports d'historique et les CSV de suivi. Deux
pieges corriges en le construisant : le tampon du BMS est circulaire,
donc le plus grand index n'est pas le plus recent - trier par index
donnait un decalage deduit de -865 h ; et les fichiers d'evenements
portent les memes colonnes sans decrire un echantillonnage regulier,
ils sont ecartes.

La deduction du decalage d'horloge reste approximative : sur BAT3 elle
donne huit heures la ou le chronometre en donne six. La console
inscrit desormais le decalage en tete de chaque export, mesure a la
seconde ou il commence ; l'importeur le prefere toujours a sa propre
deduction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-05 13:39:17 +02:00

97 lines
4.2 KiB
JavaScript

#!/usr/bin/env node
// Guette le plafond de courant de charge impose par le BMS et note chacun de
// ses changements, en y accolant les tensions de cellule relevees au meme
// moment par `pylontech-console.mjs --suivi` (le CSV le plus recent).
//
// Le point qui interesse : a quelle tension de cellule, et apres combien de
// temps, le BMS RELACHE. La retombee de l'alarme ne le dit pas — elle survient
// bien avant, le plafond restant bas plusieurs heures apres.
//
// node tools/guetter-plafond-bms.mjs [--minutes 55] [--pas 15]
import fs from 'node:fs';
import path from 'node:path';
const arg = (n, d) => { const i = process.argv.indexOf('--' + n); return i < 0 ? d : process.argv[i + 1]; };
const PAS = Math.max(5, +arg('pas', 15)) * 1000;
const FIN_MS = Date.now() + Math.max(1, +arg('minutes', 55)) * 60000;
const DOSSIER = 'docs/mesures/batteries';
const env = Object.fromEntries(
fs.readFileSync('.env', 'utf8').split('\n')
.filter((l) => l.includes('=') && !l.startsWith('#'))
.map((l) => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]));
const etat = async (id) => {
const r = await fetch('http://10.10.10.17:8123/api/states/' + id,
{ headers: { Authorization: 'Bearer ' + env.HA_TOKEN } });
return r.json();
};
// Dernier relevé console par module, depuis le CSV du suivi en cours.
function cellulesRecentes() {
let f;
try {
f = fs.readdirSync(DOSSIER).filter((n) => n.endsWith('-suivi.csv'))
.map((n) => path.join(DOSSIER, n))
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0];
} catch { return ''; }
if (!f || Date.now() - fs.statSync(f).mtimeMs > 180000) return ''; // suivi arrete
const lignes = fs.readFileSync(f, 'utf8').trim().split('\n').slice(1);
const parModule = new Map();
for (const l of lignes) { const c = l.split(','); if (c.length > 17) parModule.set(c[1], c); }
const out = [];
for (const [nom, c] of parModule) {
const v = c.slice(3, 18).map(Number).filter((x) => x > 0);
if (!v.length) continue;
const hi = Math.max(...v);
out.push(`${nom} haute ${hi} mV (cellule ${v.indexOf(hi) + 1}, ecart ${hi - Math.min(...v)} mV)`);
}
return out.join(' · ');
}
const h = () => new Date().toLocaleTimeString('fr-FR');
(async () => {
let plafond = null, depuis = Date.now(), plancher = Infinity;
const journal = [];
console.log(`Guet du plafond de charge, pas de ${PAS / 1000} s.`);
while (Date.now() < FIN_MS) {
let ccl, soc, alarme;
try {
[ccl, soc, alarme] = await Promise.all([
etat('sensor.airtria_8_us2000c_maximum_allowed_charge_current'),
etat('sensor.victron_battery_soc'),
etat('sensor.airtria_8_us2000c_tension_de_cellule_elevee'),
]);
} catch { await new Promise((r) => setTimeout(r, PAS)); continue; }
const v = parseFloat(ccl.state);
if (!Number.isFinite(v)) { await new Promise((r) => setTimeout(r, PAS)); continue; }
if (plafond === null) {
plafond = v; plancher = v;
console.log(`${h()} plafond initial ${v} A · SOC ${soc.state} % · alarme ${alarme.state}`);
console.log(` ${cellulesRecentes()}`);
} else if (v !== plafond) {
const duree = Math.round((Date.now() - depuis) / 1000);
const sens = v > plafond ? 'REMONTE' : 'baisse ';
const ligne = `${h()} ${sens} ${plafond} -> ${v} A (apres ${Math.floor(duree / 60)} min ${duree % 60} s)` +
` SOC ${soc.state} % alarme ${alarme.state}\n ${cellulesRecentes()}`;
console.log(ligne); journal.push(ligne);
const remonte = v > plafond && plafond <= plancher;
plafond = v; depuis = Date.now(); plancher = Math.min(plancher, v);
// --complet : suivre tout l'escalier de remontee au lieu de s'arreter
// a la premiere marche. La forme de la remontee est ce qui dit si le
// BMS relache par paliers fixes ou proportionnellement.
if (remonte && !arg('complet', false)) {
console.log('\n=== LE PLAFOND EST REMONTE ===');
console.log(journal.join('\n'));
process.exit(0);
}
}
await new Promise((r) => setTimeout(r, PAS));
}
console.log(`${h()} fin du guet, plafond toujours a ${plafond} A`);
if (journal.length) console.log(journal.join('\n'));
})();