Files
EVCS/tools/calibrer-anomalies.mjs
Tallagrand Thierry 60165e139b Calibration des regles d anomalie : trois tiennent, une ne tient pas
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
2026-09-14 03:22:18 +02:00

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)`);
}
}