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:
96
tools/guetter-plafond-bms.mjs
Normal file
96
tools/guetter-plafond-bms.mjs
Normal 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'));
|
||||
})();
|
||||
@@ -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
182
tools/importer-cellules.mjs
Normal 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();
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user