Files
EVCS/tools/carte-decisions.mjs
Tallagrand Thierry 6a647dc685 La carte declare un proprietaire, et le conseiller la lit
Essai du 12/09 : avec la seule liste des ecrivains, le conseiller voyait
le conflit mais accusait les deux workflows n8n — l intrus etait une
automatisation Home Assistant. La carte sait lire QUI PEUT ecrire, elle
ne peut pas deviner QUI DEVRAIT : le proprietaire est une decision
humaine, desormais declaree et commentee dans l outil.

Resultat : 3 ecrivains hors proprietaire sur le contacteur reseau, 1 sur
le courant de la borne, 1 sur le verrou. Injectee dans le briefing entre
bornes explicites, regeneree par --injecte.

Le conseiller enonce maintenant la bonne forme (« contradiction entre le
proprietaire et un autre ecrivain ») mais ne nomme toujours pas lequel :
il lui manque l ATTRIBUTION — rien ne relie une bascule a son auteur. Le
contexte Home Assistant distingue un appel de service d une
automatisation ; c est la prochaine piste.

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

210 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// CARTE DES DECISIONS — qui ecrit sur quoi, et avec quels seuils figes.
//
// Ce que le 12/09 a montre : cinq automates ecrivent sur le contacteur reseau,
// chacun localement correct, aucun ne voit les autres, et personne n avait la
// liste. Une automatisation Home Assistant coupait a 35 % de SOC pendant que le
// bilan energetique demandait 42,8 % — le conflit a dure sept mois sans etre vu.
//
// Cette carte est GENEREE depuis les sources, jamais tenue a la main : une carte
// maintenue a la main pourrit, et c est precisement ce qu on veut eviter.
//
// node tools/carte-decisions.mjs carte + ecart depuis la derniere fois
// node tools/carte-decisions.mjs --json sortie machine (entree du conseiller)
import fs from 'fs';
import os from 'os';
import path from 'path';
const HA = '/mnt/ARCH3/docker/HA';
const INSTANTANE = 'docs/mesures/carte-decisions.json';
// Actionneurs dont une ecriture concurrente a une consequence physique.
// ⚠️ LE PROPRIETAIRE EST UNE DECISION HUMAINE, PAS UNE DEDUCTION.
// La carte sait lire QUI PEUT ecrire ; elle ne peut pas deviner QUI DEVRAIT.
// Sans cette declaration, le conseiller voit bien le conflit mais designe le
// mauvais coupable — essai du 12/09 : il a accuse les deux workflows n8n alors
// que l intrus etait une automatisation Home Assistant.
// Un ecrivain hors de cette liste n est pas forcement fautif : il est A
// EXPLIQUER. C est precisement ce qui manquait pendant sept mois.
const ACTIONNEURS = [
['switch.edf_general_ok_switch', 'Contacteur reseau EDF',
'Victron - Battery / Grid Hysteresis Fix V1',
'seul cet automate ouvre et ferme le contacteur ; le controleur EVCS le DEMANDE via le helper de reprise nuit, les scripts sont des interventions manuelles assumees'],
['number.gx_device_ac_power_setpoint', 'Consigne de puissance reseau',
'HA Snapshot EVCS Surplus Controller V2.6',
'depuis le 01/09 : fermer le contacteur ne suffit pas, il faut demander la puissance'],
['switch.victron_evcs_charging', 'Charge de la borne',
'HA Snapshot EVCS Surplus Controller V2.6', 'proprietaire unique'],
['number.victron_evcs_set_current_command', 'Courant de la borne',
'HA Snapshot EVCS Surplus Controller V2.6',
'les scripts BOOST sont des interventions manuelles, reecrites au cycle suivant'],
['input_boolean.victron_grid_hysteresis_latch', 'Verrou hysteresis',
'Victron - Battery / Grid Hysteresis Fix V1', 'les scripts d urgence le forcent volontairement'],
['input_boolean.victron_evcs_night_grid_takeover_needed', 'Reprise reseau nuit',
'HA Snapshot EVCS Surplus Controller V2.6', 'proprietaire unique'],
['number.gx_device_ess_min_soc_limit', 'SOC minimal de l ESS',
null, 'aucun automate ne devrait y toucher — reglage GX'],
];
function cleApi() {
const p = path.join(os.homedir(), '.config/opencode/opencode.json');
const c = JSON.parse(fs.readFileSync(p, 'utf8'));
const e = c?.mcp?.['n8n-mcp']?.environment || {};
return { url: (e.N8N_API_URL || '').replace(/\/api\/v1\/?$/, ''), key: e.N8N_API_KEY };
}
// Une ECRITURE, pas une lecture : l entite apparait pres d un appel de service.
function ecrit(texte, entite) {
const i = texte.indexOf(entite);
if (i === -1) return null;
const services = [];
let p = 0;
while (true) {
const j = texte.indexOf(entite, p);
if (j === -1) break;
const autour = texte.slice(Math.max(0, j - 400), j + 400);
for (const m of autour.matchAll(/(turn_on|turn_off|set_value|toggle|set_current)/g)) services.push(m[1]);
p = j + 1;
}
return services.length ? [...new Set(services)] : null;
}
// Seuils numeriques compares a un SOC : la famille de valeurs qui a produit
// l incident. On les remonte tous, avec leur emplacement.
function seuilsSoc(texte, source) {
const trouves = [];
const motifs = [
/soc\w*\s*(?:<=?|>=?|===?)\s*(\d{1,3})(?:\D|$)/gi,
/(\d{1,3})\s*(?:<=?|>=?)\s*\w*soc/gi,
/\babove:\s*(\d{1,3})\b/gi,
/\bbelow:\s*(\d{1,3})\b/gi,
];
for (const re of motifs) {
for (const m of texte.matchAll(re)) {
const v = Number(m[1]);
if (v >= 5 && v <= 100) trouves.push({ valeur: v, extrait: m[0].replace(/\s+/g, ' ').slice(0, 60), source });
}
}
return trouves;
}
const sources = [];
// ── n8n ───────────────────────────────────────────────────────────────
const { url, key } = cleApi();
const r = await fetch(`${url}/api/v1/workflows?limit=100&active=true`, { headers: { 'X-N8N-API-KEY': key } });
for (const w of (await r.json()).data) {
sources.push({ famille: 'n8n', nom: w.name, id: w.id, texte: JSON.stringify(w) });
}
// ── Home Assistant ────────────────────────────────────────────────────
for (const f of ['automations.yaml', 'scripts.yaml']) {
const p = path.join(HA, f);
if (fs.existsSync(p)) sources.push({ famille: 'HA', nom: f, texte: fs.readFileSync(p, 'utf8') });
}
const paquets = path.join(HA, 'packages');
if (fs.existsSync(paquets)) {
for (const f of fs.readdirSync(paquets).filter(x => x.endsWith('.yaml'))) {
sources.push({ famille: 'HA', nom: `packages/${f}`, texte: fs.readFileSync(path.join(paquets, f), 'utf8') });
}
}
// ── Carte ─────────────────────────────────────────────────────────────
const carte = {};
for (const [entite, libelle, proprietaire, note] of ACTIONNEURS) {
carte[entite] = { libelle, proprietaire, note, ecrivains: [], lecteurs: [] };
for (const s of sources) {
if (!s.texte.includes(entite)) continue;
const services = ecrit(s.texte, entite);
// Un commentaire de desactivation ne compte pas comme un ecrivain.
const desactive = /#\s*(DESACTIVEE|DISABLED)/i.test(s.texte) && s.famille === 'HA';
if (services) carte[entite].ecrivains.push({ famille: s.famille, nom: s.nom, id: s.id, services, desactive });
else carte[entite].lecteurs.push(`${s.famille}:${s.nom}`);
}
}
const seuils = [];
for (const s of sources) seuils.push(...seuilsSoc(s.texte, `${s.famille}:${s.nom}`));
const parValeur = {};
for (const t of seuils) (parValeur[t.valeur] ||= []).push(t);
const resultat = { genere: new Date().toISOString(), carte, seuilsSoc: parValeur };
if (process.argv.includes('--json')) {
console.log(JSON.stringify(resultat, null, 2));
} else {
console.log('═══ QUI ECRIT SUR QUOI ═══\n');
for (const [entite, d] of Object.entries(carte)) {
const actifs = d.ecrivains.filter(e => !e.desactive);
console.log(`${d.libelle}`);
console.log(` ${entite}`);
if (!actifs.length) console.log(' (aucun ecrivain detecte)');
for (const e of actifs) {
console.log(` ✎ ${e.famille.padEnd(4)} ${e.nom}${e.id ? ` (${e.id})` : ''} [${e.services.join(', ')}]`);
}
for (const e of d.ecrivains.filter(x => x.desactive)) {
console.log(` ⊘ ${e.famille.padEnd(4)} ${e.nom} — desactive`);
}
console.log(` proprietaire declare : ${d.proprietaire || 'aucun'}`);
const intrus = actifs.filter(e => e.nom !== d.proprietaire);
if (intrus.length) console.log(` ⚠️ ${intrus.length} ECRIVAIN(S) HORS PROPRIETAIRE : ${intrus.map(e => e.nom).join(', ')}`);
console.log(` ${d.lecteurs.length} lecteur(s)\n`);
}
console.log('═══ SEUILS DE SOC FIGES ═══\n');
for (const v of Object.keys(parValeur).map(Number).sort((a, b) => a - b)) {
const t = parValeur[v];
const oú = [...new Set(t.map(x => x.source))];
console.log(` ${String(v).padStart(3)} % ×${String(t.length).padStart(2)} ${oú.slice(0, 3).join(', ')}${oú.length > 3 ? ` +${oú.length - 3}` : ''}`);
}
}
// ── Ecart depuis la derniere fois : c est ca, la trace des evolutions ──
if (!process.argv.includes('--json')) {
if (fs.existsSync(INSTANTANE)) {
const av = JSON.parse(fs.readFileSync(INSTANTANE, 'utf8'));
const cle = c => Object.entries(c.carte).flatMap(([e, d]) =>
d.ecrivains.filter(x => !x.desactive).map(x => `${e} <- ${x.famille}:${x.nom}`));
const avant = new Set(cle(av)), apres = new Set(cle(resultat));
const nouveaux = [...apres].filter(x => !avant.has(x));
const partis = [...avant].filter(x => !apres.has(x));
console.log(`\n═══ ECART DEPUIS ${av.genere.slice(0, 16).replace('T', ' ')} ═══`);
if (!nouveaux.length && !partis.length) console.log(' aucun changement d ecrivain');
for (const x of nouveaux) console.log(` + ${x}`);
for (const x of partis) console.log(` − ${x}`);
} else {
console.log('\n(premier instantane — l ecart sera affiche aux executions suivantes)');
}
}
// ── Injection dans le briefing du conseiller ──────────────────────────
// La carte est STRUCTURELLE : elle change quand on ajoute un automate, pas
// toutes les 15 minutes. Elle a donc sa place dans le prompt, regeneree ici
// plutot que transmise a chaque cycle. Bornes explicites pour qu une relecture
// voie tout de suite ce qui est genere et ce qui est ecrit a la main.
if (process.argv.includes('--injecte')) {
const P = 'src/advisor/BUILD_PROMPT.js';
const DEBUT = ' // <<< CARTE GENEREE — ne pas editer a la main, voir tools/carte-decisions.mjs';
const FIN = ' // CARTE GENEREE >>>';
const lignes = [" 'QUI ECRIT SUR QUOI (carte generee depuis les sources) :',"];
for (const [entite, d] of Object.entries(carte)) {
const actifs = d.ecrivains.filter(e => !e.desactive);
if (actifs.length < 2) continue;
const court = n => n.replace(/\.yaml$/, '').replace(/^(HA Snapshot |Victron - )/, '');
const intrus = actifs.filter(e => e.nom !== d.proprietaire).map(e => court(e.nom));
lignes.push(` '- ${d.libelle} : proprietaire = ${court(d.proprietaire || 'aucun')}${intrus.length ? `, AUTRES ECRIVAINS : ${intrus.join(', ')}` : ''}',`);
}
const vals = Object.keys(parValeur).map(Number).sort((a, b) => a - b);
lignes.push(` 'SEUILS DE SOC FIGES dans le code : ${vals.join(', ')} % — chacun peut contredire le bilan.',`);
lignes.push(" 'Quand un actionneur bascule de facon incoherente, le coupable le plus probable',");
lignes.push(" 'est un ecrivain AUTRE QUE SON PROPRIETAIRE. Nomme-le.',");
let src = fs.readFileSync(P, 'utf8');
const bloc = [DEBUT, ...lignes, FIN].join('\n');
src = src.includes(DEBUT)
? src.slice(0, src.indexOf(DEBUT)) + bloc + src.slice(src.indexOf(FIN) + FIN.length)
: src.replace(" 'STRATEGIES POSSIBLES :',", bloc + "\n '',\n 'STRATEGIES POSSIBLES :',");
fs.writeFileSync(P, src);
console.log(`\ncarte injectee dans ${P} (${lignes.length} lignes)`);
}
fs.mkdirSync(path.dirname(INSTANTANE), { recursive: true });
fs.writeFileSync(INSTANTANE, JSON.stringify(resultat, null, 2));