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
This commit is contained in:
Tallagrand Thierry
2026-09-05 13:39:17 +02:00
parent 1132d3147f
commit f762616893
5 changed files with 592 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
#!/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'));
})();

View File

@@ -363,6 +363,28 @@ async function migrer(client) {
'ALTER TABLE session_charge ADD COLUMN IF NOT EXISTS energie_station_kwh double precision');
await client.query(
'CREATE INDEX IF NOT EXISTS session_charge_debut_idx ON session_charge (debut DESC)');
// Tensions cellule par cellule des modules Pylontech. La source est la
// console serie du BMS, pas Home Assistant : aucune de ces valeurs n'est
// publiee cote Victron, qui n'expose que l'agregat du parc.
//
// L'identite d'un module est son CODE-BARRES, jamais son adresse : chaque
// chaine renumerote ses modules a partir de 1, donc il existe deux
// « module 1 » dans ce parc.
await client.query(`
CREATE TABLE IF NOT EXISTS cellule (
ts timestamptz NOT NULL,
module text NOT NULL,
cellule smallint NOT NULL,
mv integer NOT NULL,
soc smallint,
courant_ma integer,
etat text,
source text NOT NULL,
PRIMARY KEY (ts, module, cellule)
)`);
await client.query(
'CREATE INDEX IF NOT EXISTS cellule_module_ts_idx ON cellule (module, ts DESC)');
// Le role de lecture doit voir la nouvelle table. S'il n'existe pas encore,
// on ne fait pas echouer le demarrage pour autant.
await client.query(`DO $$ BEGIN

182
tools/importer-cellules.mjs Normal file
View File

@@ -0,0 +1,182 @@
#!/usr/bin/env node
// Verse dans PostgreSQL les tensions cellule par cellule relevees sur la
// console des BMS Pylontech, pour qu'elles soient tracables dans Grafana a
// cote du reste du systeme.
//
// Deux sources, deux formats :
// - les exports d'historique (`--historique`, ou `datalist history dtls`)
// un enregistrement toutes les 30 minutes, jusqu'a 1798 par module ;
// - les CSV de suivi (`--suivi`), au pas choisi pendant un essai.
//
// A executer LA OU le mot de passe existe, c'est-a-dire dans le conteneur
// d'historisation, qui monte le depot en lecture seule :
//
// sudo docker compose exec historisation \
// node tools/importer-cellules.mjs 'docs/mesures/batteries/*'
//
// --a-blanc analyse et compte, n'ecrit rien.
import fs from 'node:fs';
import path from 'node:path';
import pg from 'pg';
const args = process.argv.slice(2);
const BLANC = args.includes('--a-blanc');
const motifs = args.filter((a) => !a.startsWith('--'));
// Correspondance adresse -> code-barres pour les CSV de suivi, qui ne portent
// que l'adresse. Chaque chaine renumerote a partir de 1 : une adresse seule
// est ambigue dans ce parc. Completer au fil des relevés `info <n>`.
const CARTE_SUIVI = {
1: 'K220803C30160322', // pack ancien, adresse 1
3: 'K220803C30160319', // pack ancien, adresse 3
// 2 : code-barres pas encore releve -> enregistre en 'pack_ancien:2'
};
const lireTexte = (f) => {
const brut = fs.readFileSync(f);
const utf16 = brut[0] === 0xff && brut[1] === 0xfe;
return brut.toString(utf16 ? 'utf16le' : 'utf8').replace(/\r/g, '');
};
// ---------------------------------------------------------------- historique
function lireHistorique(f) {
const txt = lireTexte(f);
const bc = txt.match(/Barcode\s*:\s*(\S+)/);
if (!bc) return null;
const recs = new Map();
for (const ligne of txt.split('\n')) {
const c = ligne.split(/\s+/).filter(Boolean);
if (c.length < 18 + 15 * 6) continue;
if (!/^\d+$/.test(c[0]) || !/^\d\d-\d\d-\d\d$/.test(c[1])) continue;
const [a, m, j] = c[1].split('-').map(Number);
const [H, M, S] = c[2].split(':').map(Number);
recs.set(+c[0], {
// heure MODULE, corrigee plus bas
brut: Date.UTC(2000 + a, m - 1, j, H, M, S),
courant: +c[4], soc: parseInt(c[16], 10), etat: c[10],
mv: Array.from({ length: 15 }, (_, k) => +c[18 + k * 6 + 1]),
});
}
if (!recs.size) return null;
// L'horloge des modules derive de plusieurs heures. Plutot que de la
// demander, on la DEDUIT : l'enregistrement le plus RECENT d'un export est,
// a une demi-periode pres, contemporain de l'export lui-meme.
//
// ⚠️ Le plus recent n'est PAS celui de plus grand index : le tampon du BMS
// est circulaire, il reecrit par-dessus le plus ancien. Trier par index
// donnait des decalages de -865 h.
const rangs = [...recs.keys()].sort((x, y) => recs.get(x).brut - recs.get(y).brut);
// Depuis le 05/09, `pylontech-console.mjs --historique` inscrit le decalage
// en tete, mesure a la seconde de l'export. C'est la seule valeur exacte :
// on la prefere toujours a la deduction.
const entete = txt.match(/^# decalage_minutes\s*:\s*(-?\d+)/m);
const force = args.find((a) => a.startsWith('--decalage='));
let decalageMin, origine;
if (force) { decalageMin = +force.split('=')[1]; origine = 'impose'; }
else if (entete) { decalageMin = +entete[1]; origine = 'mesure a l\'export'; }
else {
const dernier = recs.get(rangs[rangs.length - 1]).brut;
decalageMin = Math.round((dernier - fs.statSync(f).mtimeMs) / 60000);
origine = 'DEDUIT, approximatif';
}
const decalageMs = decalageMin * 60000;
// Garde : au-dela d'une journee, la deduction ne tient plus (fichier
// recopie, horodatage de fichier perdu). On refuse plutot que de deplacer
// des mesures n'importe ou.
if (Math.abs(decalageMin) > 24 * 60) {
throw new Error(`decalage deduit aberrant (${Math.round(decalageMin / 60)} h)`);
}
const lignes = [];
for (const r of rangs) {
const x = recs.get(r);
// Le module ecrit en heure locale de Paris : on repasse par l'offset reel.
const t = new Date(x.brut - decalageMs);
const local = new Date(t.getTime() - new Date(t).getTimezoneOffset() * 60000);
for (let k = 0; k < 15; k++) {
lignes.push([local.toISOString(), bc[1], k + 1, x.mv[k], x.soc, x.courant, x.etat, 'historique']);
}
}
return { module: bc[1], decalageMin, origine, lignes, nb: rangs.length };
}
// --------------------------------------------------------------------- suivi
function lireSuivi(f) {
const lignes = [];
const modules = new Set();
for (const l of lireTexte(f).trim().split('\n').slice(1)) {
const c = l.split(',');
if (c.length < 18) continue;
const ad = parseInt(String(c[1]).replace(/\D/g, ''), 10);
if (!Number.isFinite(ad)) continue;
const mod = CARTE_SUIVI[ad] || `pack_ancien:${ad}`;
modules.add(mod);
const cour = c[2] === '' ? null : +c[2];
for (let k = 0; k < 15; k++) {
const mv = +c[3 + k];
if (!(mv > 1000)) continue;
lignes.push([c[0], mod, k + 1, mv, null, cour, c[1] || null, 'suivi']);
}
}
return lignes.length ? { modules: [...modules], lignes } : null;
}
// ---------------------------------------------------------------------- main
const fichiers = [];
for (const m of motifs) {
const d = path.dirname(m), base = path.basename(m);
const re = new RegExp('^' + base.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
for (const n of fs.readdirSync(d)) if (re.test(n)) fichiers.push(path.join(d, n));
}
if (!fichiers.length) { console.error('Aucun fichier.'); process.exit(1); }
const paquets = [];
for (const f of fichiers.sort()) {
const n = path.basename(f);
try {
if (n.endsWith('-suivi.csv')) {
const r = lireSuivi(f);
if (r) { paquets.push(r.lignes); console.log(` ${n} suivi, ${r.lignes.length / 15} releves, modules ${r.modules.join(' ')}`); }
} else if (/histor/i.test(n) && n.endsWith('.txt')) {
// Les exports d'EVENEMENTS portent les memes colonnes mais decrivent des
// declenchements, pas un echantillonnage regulier : les melanger ferait
// des courbes fausses.
const r = lireHistorique(f);
if (r) {
paquets.push(r.lignes);
const d = r.decalageMin;
const t0 = r.lignes[0][0].slice(0, 16).replace('T', ' ');
const t1 = r.lignes[r.lignes.length - 1][0].slice(0, 16).replace('T', ' ');
console.log(` ${n}\n ${r.module}, ${r.nb} enregistrements, horloge `
+ `${d >= 0 ? '+' : '-'}${Math.trunc(Math.abs(d) / 60)} h ${Math.abs(d % 60)} min`
+ ` (${r.origine}) -> ${t0} a ${t1}`);
}
}
} catch (e) { console.error(` ${n} ignore : ${e.message}`); }
}
const toutes = paquets.flat();
console.log(`\n${toutes.length} lignes cellule au total.`);
if (BLANC) { console.log('A blanc : rien ecrit.'); process.exit(0); }
const client = new pg.Client({
host: process.env.PGHOST || '10.10.10.22',
port: Number(process.env.PGPORT || 5432),
user: process.env.PGUSER || 'evcs',
database: process.env.PGDATABASE || 'evcs',
password: process.env.PGPASSWORD,
});
await client.connect();
let ecrites = 0;
for (let i = 0; i < toutes.length; i += 500) {
const lot = toutes.slice(i, i + 500);
const val = lot.map((_, k) => `($${k * 8 + 1},$${k * 8 + 2},$${k * 8 + 3},$${k * 8 + 4},$${k * 8 + 5},$${k * 8 + 6},$${k * 8 + 7},$${k * 8 + 8})`).join(',');
const r = await client.query(
`INSERT INTO cellule (ts, module, cellule, mv, soc, courant_ma, etat, source)
VALUES ${val} ON CONFLICT (ts, module, cellule) DO NOTHING`, lot.flat());
ecrites += r.rowCount;
process.stdout.write(`\r ${i + lot.length} / ${toutes.length} `);
}
console.log(`\n${ecrites} lignes nouvelles, ${toutes.length - ecrites} deja presentes.`);
await client.end();

View File

@@ -208,6 +208,25 @@ async function main() {
console.log(`Extraction de l'historique a partir de l'enregistrement ${depuis}.`);
console.log('Le module en garde 1798, un toutes les 30 minutes : comptez plusieurs minutes.');
const f = journal(nom, `# releve du ${horodate()} sur ${dev}\n`);
// L'horloge du module derive de plusieurs heures et chaque module a la
// sienne. On la CAPTURE ici, a la seconde ou l'export commence, plutot que
// de la deduire ensuite : le tampon d'historique est circulaire et contient
// des enregistrements anterieurs a des remises a l'heure, ce qui rend toute
// deduction a posteriori fausse de plusieurs heures.
const rtc = await commande(l, 'time', 8000);
const m = rtc.match(/RTC\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
if (m) {
const reel = new Date();
const mod = new Date(m[1].replace(' ', 'T') + 'Z');
const decMin = Math.round((mod.getTime() -
(reel.getTime() - reel.getTimezoneOffset() * 60000)) / 60000);
journal(nom, `# RTC module : ${m[1]}\n# heure reelle : ${reel.toISOString()}\n`
+ `# decalage_minutes : ${decMin}\n`);
console.log(`Horloge du module : ${m[1]} — decalage ${decMin} min sur l'heure reelle.`);
} else {
journal(nom, '# RTC module : non lu\n');
console.log('Horloge du module non lue : le decalage devra etre donne a l\'import.');
}
journal(nom, await commande(l, 'info', 8000));
// UNE seule commande suffit : le module pagine lui-meme jusqu'au dernier
// enregistrement, `commande` tourne les pages. Boucler sur des index