Files
EVCS/tools/importer-cellules.mjs
Tallagrand Thierry a4de466ae1 Le releve continu, et le code-barres dans le CSV plutot que l'adresse
Le suivi n'ecrivait que l'adresse du module, traduite a l'import par
une table figee sur le pack ancien. Le cable ayant change de pack,
tout releve fait maintenant aurait ete attribue aux mauvais modules.
Le code-barres est desormais resolu au demarrage et ecrit dans le CSV ;
la table ne sert plus qu'aux anciens fichiers.

Le releve tourne sur le poste qui porte le cable serie - le port USB
est la, pas sur le Synology - et n'ecrit qu'un CSV dans le depot
partage. C'est le service d'historisation, cote Synology, qui le verse
en base : le poste n'a donc aucun mot de passe a connaitre.

Archive pour le constructeur : un dossier par code-barres, plus un
manifeste en francais, anglais et chinois qui dit ou regarder en
premier et signale que les CSV sont deja corriges de la derive
d'horloge des BMS.

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

269 lines
12 KiB
JavaScript

#!/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>`.
// Decalage d'horloge MESURE au chronometre, par module, en minutes. Ces
// valeurs viennent de la lecture directe du RTC face a l'horloge du poste, le
// 05/09/2026 — pas d'une deduction. Chaque module a son propre RTC.
//
// chaine du pack ancien, `pwr` a 12:16 -> RTC 18:07:52 => +5 h 52
// module isole (…0324), `time` a 12:47 -> RTC 18:56:35 => +6 h 09
// chaine du pack recent, `pwr` a 12:23 -> RTC 18:36:33 => +6 h 13
//
// La deduction automatique se trompait de 1 h 30 a 4 h : le tampon du BMS est
// circulaire et contient des enregistrements anterieurs a des remises a
// l'heure, donc le plus recent des horodatages n'est pas fiable.
// Repere : ces valeurs ont ete mesurees au chronometre le 05/09/2026 et
// servent a CONTROLER la deduction, pas a la remplacer.
// pack ancien +5 h 52 · module isole +6 h 09 · pack recent +6 h 13
const CARTE_SUIVI = {
1: 'K220803C30160322', // pack ancien, adresse 1
2: 'K220803C30160323', // pack ancien, adresse 2 — le module sain de sa chaine
3: 'K220803C30160319', // pack ancien, adresse 3
};
// ⚠️ Cette carte ne vaut QUE pour la chaine du pack ancien. Le pack recent
// renumerote lui aussi ses modules de 1 a 4 : y appliquer cette carte
// attribuerait ses relevés aux mauvais modules. Un suivi fait sur l'autre
// chaine demandera une carte distincte, choisie a l'import.
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. Le module ecrit une heure de mur
// LOCALE : il faut l'interpreter comme telle, jamais comme de l'UTC.
// Le faire ajoutait exactement le decalage de Paris (2 h en ete) a
// chaque decalage d'horloge deduit — d'ou les « +7 h 27 » et « +9 h 59 »
// qui semblaient absurdes et qui, corriges, tombent sur les valeurs
// mesurees au chronometre.
brut: new Date(2000 + a, m - 1, j, H, M, S).getTime(),
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);
const t = new Date(x.brut - decalageMs);
for (let k = 0; k < 15; k++) {
lignes.push([t.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 };
}
// ------------------------------------------------- historique distant (CSV)
// Produit par `--historique-distant <adresse>` : deja horodate a l'heure
// REELLE (le decalage est lu et applique a l'ecriture) et deja nomme par
// code-barres. Rien a corriger, rien a deviner.
function lireHistoDistant(f) {
const lignes = [];
const l = lireTexte(f).trim().split('\n');
const cols = l[0].split(',');
const iC = cols.indexOf('c1_mV');
for (const ligne of l.slice(1)) {
const c = ligne.split(',');
if (c.length < iC + 15) continue;
for (let k = 0; k < 15; k++) {
const mv = +c[iC + k];
if (!(mv > 1000)) continue;
lignes.push([c[0], c[1], k + 1, mv, c[4] === '' ? null : +c[4],
c[3] === '' ? null : +c[3], c[2] || null, 'historique']);
}
}
return lignes.length ? { module: l[1].split(',')[1], lignes } : null;
}
// --------------------------------------------------------------------- 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;
// Depuis le 05/09 le suivi ecrit directement le code-barres. Les fichiers
// anterieurs ne portent que `moduleN` : la table ci-dessus les traduit, et
// elle ne vaut que pour la chaine du pack ancien, seule interrogee alors.
const brut = String(c[1]);
let mod;
if (/^[A-Z]\d{6}[A-Z]\d+$/.test(brut)) mod = brut;
else {
const ad = parseInt(brut.replace(/\D/g, ''), 10);
if (!Number.isFinite(ad)) continue;
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); }
// Un module extrait par `--historique-distant` a des horodatages EXACTS (le
// decalage est lu au chronometre). Ses vieux exports texte, eux, reposent sur
// une deduction. Importer les deux pour le meme module poserait deux series
// decalees de plusieurs heures l'une de l'autre, et les courbes seraient
// illisibles. La source exacte gagne, quitte a perdre quelques enregistrements.
const exacts = new Set();
for (const f of fichiers) {
const m = path.basename(f).match(/-histo-distant-(\S+?)\.csv$/);
if (m) exacts.add(m[1]);
}
const paquets = [];
for (const f of fichiers.sort()) {
const n = path.basename(f);
try {
if (n.includes('-histo-distant-')) {
const r = lireHistoDistant(f);
if (r) { paquets.push(r.lignes); console.log(` ${n}\n ${r.module}, ${r.lignes.length / 15} enregistrements, horodates a l'export`); }
} else 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')) {
const bc = (lireTexte(f).match(/Barcode\s*:\s*(\S+)/) || [])[1];
if (bc && exacts.has(bc)) {
console.log(` ${n} ecarte : ${bc} a un releve horodate a l'export`);
continue;
}
// 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();
// --remplacer : effacer d'abord les lignes des modules concernes pour la meme
// source. Sans cela, corriger un decalage d'horloge AJOUTE une seconde copie
// decalee au lieu de remplacer la premiere — la cle primaire porte l'instant.
if (args.includes('--remplacer')) {
// Les noms provisoires de la forme `pack_ancien:2` datent d'avant que le
// code-barres du module soit connu. Une fois nomme, l'ancien nom ferait un
// module fantome dans les listes.
const prov = await client.query("DELETE FROM cellule WHERE module LIKE '%:%'");
if (prov.rowCount) console.log(` efface ${prov.rowCount} lignes sous un nom provisoire`);
const paires = [...new Set(toutes.map((l) => l[1] + '\u0000' + l[7]))]
.map((x) => x.split('\u0000'));
for (const [mod, src] of paires) {
const r = await client.query('DELETE FROM cellule WHERE module = $1 AND source = $2', [mod, src]);
console.log(` efface ${r.rowCount} lignes de ${mod} (${src})`);
}
}
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();