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
This commit is contained in:
@@ -515,6 +515,49 @@ async function ecrireSession(sql, client, st) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Verse dans la base les relevés de cellule deposes par le poste qui porte le
|
||||
// cable serie. Celui-ci n'ecrit qu'un CSV dans le depot partage : il n'a donc
|
||||
// aucun mot de passe de base a connaitre, et le service d'ici, qui l'a deja,
|
||||
// fait l'ingestion. `ON CONFLICT DO NOTHING` rend l'operation idempotente,
|
||||
// donc relire le meme fichier a chaque cycle ne coute qu'une requete.
|
||||
const DOSSIER_CELLULES = 'docs/mesures/batteries';
|
||||
async function ingererCellules(client) {
|
||||
let fichiers;
|
||||
try { fichiers = fs.readdirSync(DOSSIER_CELLULES); } catch { return 0; }
|
||||
let lignes = 0;
|
||||
for (const n of fichiers) {
|
||||
if (!n.endsWith('-suivi.csv')) continue;
|
||||
const f = path.join(DOSSIER_CELLULES, n);
|
||||
// Seuls les fichiers vivants : un releve arrete depuis plus d'une heure a
|
||||
// deja ete verse, le relire serait du bruit.
|
||||
if (Date.now() - fs.statSync(f).mtimeMs > 3600000) continue;
|
||||
const l = fs.readFileSync(f, 'utf8').trim().split('\n');
|
||||
const cols = l[0].split(',');
|
||||
const iC = cols.indexOf('c1_mV');
|
||||
if (iC < 0) continue;
|
||||
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
|
||||
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']);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < val.length; i += 500) {
|
||||
const lot = val.slice(i, i + 500);
|
||||
const ph = 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 ${ph} ON CONFLICT (ts, module, cellule) DO NOTHING`, lot.flat());
|
||||
lignes += r.rowCount;
|
||||
}
|
||||
}
|
||||
return lignes;
|
||||
}
|
||||
|
||||
async function cycle(client) {
|
||||
// En blanc, on compte ce qu'on ECRIRAIT. Un essai a blanc qui renvoie
|
||||
// toujours zero ne verifie rien — c'est le piege classique du test qui ne
|
||||
@@ -535,13 +578,20 @@ async function cycle(client) {
|
||||
prevision: await ecrirePrevision(sql, st),
|
||||
session: await ecrireSession(sql, client, st),
|
||||
evenement: await detecterEvenements(sql, st),
|
||||
// Ingestion des relevés de cellule deposes par le poste au cable serie.
|
||||
// Hors essai a blanc : l'insertion est massive et idempotente, la simuler
|
||||
// n'apprendrait rien.
|
||||
cellules: DRY ? 0 : await ingererCellules(client).catch((e) => {
|
||||
console.error(`ingestion cellules en echec : ${e.message}`);
|
||||
return 0;
|
||||
}),
|
||||
};
|
||||
const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement + r.session;
|
||||
const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement + r.session + r.cellules;
|
||||
if (total > 0 || ONCE) {
|
||||
console.log(
|
||||
`${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} ` +
|
||||
`${DRY ? '[BLANC] ' : ''}mesures=${r.mesures} bilan=${r.bilan} ` +
|
||||
`decision=${r.decision} prevision=${r.prevision} session=${r.session} evenement=${r.evenement}`
|
||||
`decision=${r.decision} prevision=${r.prevision} session=${r.session} evenement=${r.evenement} cellules=${r.cellules}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,9 +152,17 @@ function lireSuivi(f) {
|
||||
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}`;
|
||||
// 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++) {
|
||||
|
||||
143
tools/preparer-archive-pylontech.mjs
Normal file
143
tools/preparer-archive-pylontech.mjs
Normal file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
// Rassemble tout ce qui concerne les BMS Pylontech en une archive lisible par
|
||||
// un tiers : un dossier par module, nomme par son code-barres, plus un
|
||||
// manifeste trilingue qui dit quel module est quoi et ou regarder en premier.
|
||||
//
|
||||
// node tools/preparer-archive-pylontech.mjs
|
||||
//
|
||||
// Le tri se fait sur le CONTENU des fichiers (code-barres lu dedans), jamais
|
||||
// sur leur nom : les exports BatteryView et les miens ne se nomment pas pareil.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const SRC = 'docs/mesures/batteries';
|
||||
const OUT = `docs/mesures/archive-pylontech-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}`;
|
||||
|
||||
// Etat de chaque module, tel qu'etabli le 05/09/2026. Sert au manifeste.
|
||||
const MODULES = {
|
||||
K220803C30160322: { pack: '2022', adr: 1, cellules: '6, 7, 12, 14', med: 108, ev: 850, etat: 'atteint' },
|
||||
K220803C30160323: { pack: '2022', adr: 2, cellules: '—', med: 6, ev: 0, etat: 'temoin' },
|
||||
K220803C30160319: { pack: '2022', adr: 3, cellules: '2, 6, 10, 12, 15', med: 45, ev: 688, etat: 'atteint' },
|
||||
K220803C30160324: { pack: '2022', adr: 4, cellules: '15', med: 57, ev: 900, etat: 'atteint' },
|
||||
Y230202C30371426: { pack: '2023', adr: 1, cellules: '—', med: 2, ev: 19, etat: 'sain' },
|
||||
Y230202C30371425: { pack: '2023', adr: 2, cellules: '—', med: 2, ev: 12, etat: 'sain' },
|
||||
Y230202C30371431: { pack: '2023', adr: 3, cellules: '—', med: 2, ev: 6, etat: 'sain' },
|
||||
Y230202C30371427: { pack: '2023', adr: 4, cellules: '—', med: 2, ev: 1, etat: 'sain' },
|
||||
};
|
||||
|
||||
const lire = (f) => {
|
||||
const b = fs.readFileSync(f);
|
||||
return b.toString(b[0] === 0xff && b[1] === 0xfe ? 'utf16le' : 'utf8');
|
||||
};
|
||||
|
||||
fs.rmSync(OUT, { recursive: true, force: true });
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const classe = {};
|
||||
let communs = 0;
|
||||
for (const n of fs.readdirSync(SRC)) {
|
||||
const f = path.join(SRC, n);
|
||||
if (!fs.statSync(f).isFile()) continue;
|
||||
let bc = null;
|
||||
for (const code of Object.keys(MODULES)) {
|
||||
if (n.includes(code)) { bc = code; break; }
|
||||
}
|
||||
if (!bc && /\.(txt|csv)$/i.test(n)) {
|
||||
const t = lire(f).slice(0, 200000);
|
||||
for (const code of Object.keys(MODULES)) if (t.includes(code)) { bc = code; break; }
|
||||
}
|
||||
const dest = bc ? path.join(OUT, `${MODULES[bc].pack}-${bc}`) : path.join(OUT, 'commun');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.copyFileSync(f, path.join(dest, n));
|
||||
if (bc) (classe[bc] ??= []).push(n); else communs++;
|
||||
}
|
||||
|
||||
const ligne = (bc) => {
|
||||
const m = MODULES[bc];
|
||||
return `| ${bc} | ${m.pack} | ${m.adr} | ${m.cellules} | ${m.med} mV | ${m.ev} |`;
|
||||
};
|
||||
const ordre = Object.keys(MODULES);
|
||||
const tableau = ordre.map(ligne).join('\n');
|
||||
|
||||
fs.writeFileSync(path.join(OUT, 'LISEZ-MOI.md'), `# Relevés BMS Pylontech — installation Victron ESS
|
||||
*Généré le ${new Date().toLocaleDateString('fr-FR')}*
|
||||
|
||||
## Français
|
||||
|
||||
Huit modules US2000C en deux packs de quatre reliés par un LV-HUB. Toutes les
|
||||
valeurs ci-dessous sortent des BMS eux-mêmes, lues sur leur console série
|
||||
(\`info\`, \`stat\`, \`soh\`, \`data … history\`, \`data … event\`).
|
||||
|
||||
| Code-barres | Pack | Adr. | Cellules déviantes | Écart médian | Événements |
|
||||
|---|---|---:|---|---:|---:|
|
||||
${tableau}
|
||||
|
||||
**Où regarder en premier** : \`2022-K220803C30160324\`, fichier
|
||||
\`*-evenements-distant-*.csv\`, ligne du 23/07/2026 17:20 — la cellule 15 est à
|
||||
2963 mV **à 84 % de charge** sous 6,5 A, quand ses quatorze voisines sont à
|
||||
3291-3303 mV.
|
||||
|
||||
**Ce qui écarte une cause d'installation** : le module K220803C30160323 partage
|
||||
la chaîne, les courants et les journées de deux modules atteints, et n'a
|
||||
enregistré **aucun** événement en seize mois.
|
||||
|
||||
⚠️ **Horodatages** : les horloges internes des BMS avançaient de 6 h 09 à 6 h 13
|
||||
au moment des relevés. **Tous les fichiers CSV de ce dossier sont déjà corrigés
|
||||
à l'heure réelle (Europe/Paris)**. Les exports BatteryView \`*.txt\`, eux, portent
|
||||
l'heure brute du module.
|
||||
|
||||
## English
|
||||
|
||||
Eight US2000C modules in two packs of four joined by an LV-HUB. Every value
|
||||
below comes from the BMS units themselves, read over their serial console.
|
||||
|
||||
**Where to look first**: \`2022-K220803C30160324\`, file
|
||||
\`*-evenements-distant-*.csv\`, row of 2026-07-23 17:20 — cell 15 sits at
|
||||
2963 mV **at 84 % state of charge** under only 6.5 A, while its fourteen
|
||||
neighbours are at 3291-3303 mV.
|
||||
|
||||
**What rules out an installation cause**: module K220803C30160323 shares the
|
||||
chain, the currents and the days of two affected modules, and has recorded
|
||||
**zero** events in sixteen months.
|
||||
|
||||
⚠️ **Timestamps**: the BMS internal clocks ran 6 h 09 to 6 h 13 fast. **All CSV
|
||||
files here are already corrected to real local time (Europe/Paris).** The
|
||||
BatteryView \`*.txt\` exports carry the raw module clock.
|
||||
|
||||
## 中文
|
||||
|
||||
八个 US2000C 模块,分为两组,每组四个,通过 LV-HUB 连接。以下所有数值均直接
|
||||
读取自各模块 BMS 的串口控制台。
|
||||
|
||||
**建议优先查看**:\`2022-K220803C30160324\` 目录下的
|
||||
\`*-evenements-distant-*.csv\`,2026-07-23 17:20 这一行 —— 在 **84 % 荷电状态**
|
||||
下、仅 6.5 A 放电电流时,15 号电芯降至 2963 mV,而其余十四颗仍在
|
||||
3291-3303 mV。
|
||||
|
||||
**可排除安装或使用原因的依据**:模块 K220803C30160323 与两个受损模块位于同一
|
||||
通信链,经历相同电流与相同工况,十六个月内**没有任何**事件记录。
|
||||
|
||||
⚠️ **时间戳**:采集时各 BMS 内部时钟快 6 小时 09 分至 6 小时 13 分。**本目录中
|
||||
所有 CSV 文件均已校正为真实当地时间(欧洲/巴黎)**;BatteryView 导出的
|
||||
\`*.txt\` 文件仍为模块原始时钟。
|
||||
|
||||
## Fichiers / Files / 文件
|
||||
|
||||
- \`*-histo-distant-*.csv\` — historique, un point toutes les 30 min, 15 tensions de cellule
|
||||
- \`*-evenements-distant-*.csv\` — journal d'événements, à la seconde, déclenchements uniquement
|
||||
- \`*-console.txt\` — sorties \`info\`, \`stat\`, \`soh\`, \`pwr\` verbatim
|
||||
- \`*_history_*.txt\`, \`*_event_*.txt\` — exports BatteryView d'origine (UTF-16)
|
||||
`);
|
||||
|
||||
console.log(`Archive : ${OUT}`);
|
||||
for (const bc of ordre) {
|
||||
const n = (classe[bc] || []).length;
|
||||
console.log(` ${MODULES[bc].pack}-${bc} ${String(n).padStart(2)} fichier(s) ${MODULES[bc].etat}`);
|
||||
}
|
||||
console.log(` commun ${String(communs).padStart(2)} fichier(s)`);
|
||||
|
||||
const tgz = `${OUT}.tar.gz`;
|
||||
execFileSync('tar', ['czf', tgz, '-C', path.dirname(OUT), path.basename(OUT)]);
|
||||
console.log(`\nArchive compressee : ${tgz} (${(fs.statSync(tgz).size / 1048576).toFixed(1)} Mo)`);
|
||||
@@ -346,8 +346,25 @@ async function main() {
|
||||
// Sans cet argument on ne lit que le module sur lequel le cable est pose.
|
||||
const adresses = String(arg('modules', '')).split(',')
|
||||
.map((x) => parseInt(x, 10)).filter((n) => n >= 1 && n <= 16);
|
||||
|
||||
// ⚠️ On resout le CODE-BARRES de chaque adresse AVANT de commencer, et on
|
||||
// l'ecrit dans le CSV. Chaque chaine renumerote ses modules a partir de 1 :
|
||||
// une adresse seule est ambigue, et une table de correspondance figee
|
||||
// attribuerait les relevés d'une chaine aux modules de l'autre. C'est
|
||||
// exactement le piege ouvert quand le cable a change de pack le 05/09.
|
||||
const codes = new Map();
|
||||
for (const ad of adresses) {
|
||||
const info = await commande(l, `info ${ad}`, 8000);
|
||||
const bc = (info.match(/Barcode\s*:\s*(\S+)/) || [])[1];
|
||||
if (!bc) {
|
||||
console.log(`Code-barres illisible pour l'adresse ${ad} : on n'enregistre pas a l'aveugle.`);
|
||||
console.log('Relancer la commande — la ligne aura eu le temps de se vider.');
|
||||
process.exit(1);
|
||||
}
|
||||
codes.set(ad, bc);
|
||||
}
|
||||
console.log(`Suivi toutes les ${pas} s, cellule par cellule -> ${f}`);
|
||||
if (adresses.length) console.log(`Adresses interrogees : ${adresses.join(', ')}`);
|
||||
for (const [ad, bc] of codes) console.log(` adresse ${ad} = ${bc}`);
|
||||
console.log('L\'heure enregistree est l\'heure REELLE de cette machine, pas celle du module');
|
||||
console.log('(l\'horloge interne du module derive de plusieurs heures).');
|
||||
console.log('Ctrl-C pour arreter.\n');
|
||||
@@ -364,8 +381,8 @@ async function main() {
|
||||
if (c.length < 10) { console.log(`${horodate()} module ${ad} : illisible`); continue; }
|
||||
const v = c.map((x) => x.mV);
|
||||
const hi = Math.max(...v), lo = Math.min(...v);
|
||||
fs.appendFileSync(f, [new Date().toISOString(), `module${ad}`, '', ...v, hi - lo].join(',') + '\n');
|
||||
console.log(`${horodate()} module ${ad} haute ${hi} mV (cellule ${c.find((x) => x.mV === hi).n})` +
|
||||
fs.appendFileSync(f, [new Date().toISOString(), codes.get(ad), '', ...v, hi - lo].join(',') + '\n');
|
||||
console.log(`${horodate()} ${codes.get(ad).slice(-4)} haute ${hi} mV (cellule ${c.find((x) => x.mV === hi).n})` +
|
||||
` basse ${lo} mV ecart ${String(hi - lo).padStart(3)} mV` +
|
||||
(hi >= 3600 ? ' NIVEAU 4' : hi >= 3550 ? ' NIVEAU 3, le courant se reduit' : ''));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user