L oscillation rapide se calibre sans ambiguite : sur le contacteur reseau, le plus grand ecart pathologique vaut 193 s et le plus petit ecart normal 857 s, facteur 4,4 sans aucune valeur entre les deux. Quatre bascules en dix minutes isole exactement la salve du 12/09 et reste muet sur dix entites pendant dix jours. Piege trouve en calibrant : les plus petits ecarts normaux de plusieurs entites valent 242 a 320 s, c est-a-dire la cadence des workflows. Ce ne sont pas des oscillations mais deux executions consecutives qui changent d avis — toute regle a deux bascules en cinq minutes se declenche sur le fonctionnement normal. En revanche la regle des sessions reseau repetees NE SE CALIBRE PAS : les ecarts entre fermetures forment un continuum de 4 minutes a 24 heures, sans discontinuite. Or c est la pathologie la plus couteuse des deux. Je ne pose pas de seuil dessus : elle demande une formulation metier et plus d historique, et la bacler serait refaire l erreur que ce projet a payee six fois. La regle de la mesure figee, elle, se valide sur un incident reel : le 05/09 la tension et le courant batterie se sont tus 27 minutes pendant que le SOC, issu du meme flux, publiait toutes les deux minutes. Le discriminant est relatif, pas absolu — un silence long est normal pour une grandeur intermittente. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
70 lines
3.3 KiB
JavaScript
70 lines
3.3 KiB
JavaScript
#!/usr/bin/env node
|
|
// CALIBRATION DES REGLES D ANOMALIE — LECTURE SEULE, rejouable.
|
|
//
|
|
// node tools/calibrer-anomalies.mjs
|
|
//
|
|
// On ne fige pas un seuil au juge. Les six oscillateurs de ce projet sont tous
|
|
// nes de constantes posees « au pif » (cf. la memoire du 24/08). Un seuil se
|
|
// CHOISIT sur l historique, en exigeant qu il SEPARE le cas pathologique connu
|
|
// des bascules normales — et s il ne separe pas, on ne le fige pas.
|
|
//
|
|
// A REJOUER apres toute evolution du parc ou des workflows : la cadence des
|
|
// automates change les ecarts, donc les seuils.
|
|
// On ne fige pas un seuil au juge : on le choisit sur l historique, en exigeant
|
|
// qu il SEPARE le cas pathologique connu (la salve du 12/09) des bascules
|
|
// normales du cycle jour/nuit.
|
|
import fs from 'fs';
|
|
import { execFileSync } from 'child_process';
|
|
const env = fs.readFileSync('.env', 'utf8');
|
|
const mdp = (env.match(/^PG_LECTURE_PASSWORD=(.*)$/m)?.[1] || '').trim().replace(/^["']|["']$/g, '');
|
|
const sql = (q) => execFileSync('psql',
|
|
['-h','10.10.10.22','-p','5433','-U','evcs_lecture','-d','evcs','-At','-F','\t','-c',q],
|
|
{ env: { ...process.env, PGPASSWORD: mdp }, encoding: 'utf8' })
|
|
.trim().split('\n').filter(Boolean).map(l => l.split('\t'));
|
|
|
|
const ENTITES = sql(`select detail->>'entite', count(*) from evenement
|
|
where detail ? 'avant' group by 1 having count(*) >= 10 order by 2 desc`);
|
|
|
|
// Un episode = N bascules dans une fenetre glissante de T secondes.
|
|
function episodes(ts, N, T) {
|
|
const out = [];
|
|
for (let i = 0; i + N - 1 < ts.length; i++) {
|
|
if (ts[i + N - 1] - ts[i] <= T * 1000) {
|
|
const dernier = out[out.length - 1];
|
|
// On etend l episode en cours au lieu d en ouvrir un par fenetre.
|
|
if (dernier && ts[i] - dernier.fin <= T * 1000) { dernier.fin = ts[i + N - 1]; dernier.n++; }
|
|
else out.push({ debut: ts[i], fin: ts[i + N - 1], n: N });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const fr = (ms) => new Date(ms).toLocaleString('fr-FR', { timeZone: 'Europe/Paris', day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' });
|
|
|
|
console.log('ENTITE bascules plus petit ecart');
|
|
console.log('-'.repeat(78));
|
|
const donnees = new Map();
|
|
for (const [e, n] of ENTITES) {
|
|
const ts = sql(`select extract(epoch from ts)*1000 from evenement
|
|
where detail->>'entite'='${e}' order by ts`).map(r => Number(r[0]));
|
|
donnees.set(e, ts);
|
|
let min = Infinity;
|
|
for (let i = 1; i < ts.length; i++) min = Math.min(min, (ts[i] - ts[i-1]) / 1000);
|
|
console.log(`${e.slice(0,44).padEnd(46)} ${String(n).padStart(5)} ${Math.round(min)} s`);
|
|
}
|
|
|
|
console.log('\n\nCE QUE CHAQUE COUPLE (N, T) DECLENCHE\n' + '='.repeat(78));
|
|
for (const [N, T] of [[3,600],[3,300],[4,600],[2,300],[3,900]]) {
|
|
console.log(`\n--- ${N} bascules en ${T/60} min ---`);
|
|
for (const [e, ts] of donnees) {
|
|
const eps = episodes(ts, N, T);
|
|
const court = e.replace(/^(switch|input_boolean|input_text|binary_sensor|sensor)\./,'');
|
|
if (!eps.length) { console.log(` ${court.slice(0,40).padEnd(42)} muet`); continue; }
|
|
console.log(` ${court.slice(0,40).padEnd(42)} ${eps.length} episode(s)`);
|
|
for (const x of eps.slice(0, 4)) {
|
|
console.log(` ${fr(x.debut)} -> ${fr(x.fin)} ${x.n} bascules en ${Math.round((x.fin-x.debut)/1000)} s`);
|
|
}
|
|
if (eps.length > 4) console.log(` … et ${eps.length - 4} autre(s)`);
|
|
}
|
|
}
|