Diagnostics PV et cellule calcules cote service pour l'agent vocal (G5/G13b)

La fiche OpenClaw basculait G5 de 7/7 a 1/7 sur un ajout sans rapport :
les deux jugements sortent du modele. sensor.evcs_pv_diagnostic (import
teste avant bridage) et sensor.evcs_cellules (perime => aucune valeur
visible), publies meme quand le cycle echoue. Tests 15/15 + mutations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z
This commit is contained in:
Tallagrand Thierry
2026-09-25 00:55:17 +02:00
parent d64b634a66
commit b9142aac54
4 changed files with 293 additions and 2 deletions

137
tools/diagnostics-agent.mjs Normal file
View File

@@ -0,0 +1,137 @@
// =====================================================================
// Diagnostics publies pour l'agent vocal OpenClaw — calcules ICI, pas par lui.
// =====================================================================
//
// POURQUOI (25/09/2026). Le banc PV d'OpenClaw a montre que la fiche de l'agent
// est sur le fil du rasoir : 96 caracteres ajoutes dans le bloc CELLULE, sans
// aucun rapport avec la substitution, ont fait tomber G5 (substitution EDF) de
// 7/7 a 1/7. Une regle qu'un ajout sans rapport fait basculer cassera au
// prochain ajout. On sort donc les deux jugements du modele : ils sont calcules
// ici, testes (tests/diagnostics-agent/run.mjs), et l'agent n'a plus qu'a LIRE
// un verdict et l'expliquer.
//
// Fonctions PURES (etats HA en entree, objet en sortie) : aucun appel reseau,
// pour que les tests rejouent exactement ce qui tourne.
// =====================================================================
// Les sept chargeurs lus par l'agent (MANUEL §0, HANDOFF §77). Le RS 450/200
// expose un mode d'appareil ET un mode par tracker : on les compte tous, comme
// le banc.
export const MPPT_MODES = [
'sensor.airtria_mppt1_mppt_operation_mode',
'sensor.mppt_450_200_hq2449yr9pt_mppt_operation_mode',
'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_1_operation_mode',
'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_2_operation_mode',
'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_3_operation_mode',
'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_4_operation_mode',
'sensor.v1_mppt_operation_mode',
];
// Bruit de mesure du compteur reseau, PAS un reglage de decision : contacteur
// ferme, l'ESS preleve quelques dizaines de watts meme a consigne nulle. En
// dessous, « import ≈ 0 » au sens de la regle du 31/08.
export const IMPORT_BRUIT_W = 100;
// Sous cette puissance PV, il n'y a pas de production a discuter (nuit, aube).
export const PV_NUIT_W = 50;
function nombre(st, id) {
const s = st.get(id);
if (!s) return null;
const v = Number(s.state);
return Number.isFinite(v) ? v : null;
}
// ---------------------------------------------------------------------
// G5 — bridage normal, vraie baisse, ou substitution par EDF ?
// ---------------------------------------------------------------------
// Regle du 31/08 (HANDOFF §26-§31, §77) : un chargeur qui se retient prouve
// qu'il reste de la ressource refusee. Si le reseau n'importe rien, c'est le
// bridage normal d'une installation sans injection. S'il importe, EDF fournit
// ce que le solaire aurait pu donner : c'est la SUBSTITUTION, l'anomalie.
// L'import se verifie AVANT de conclure au bridage, par construction : c'est
// l'ordre des tests ci-dessous.
export function diagnosticPv(st) {
const pv = nombre(st, 'sensor.victron_pv_power_system');
const conso = nombre(st, 'sensor.victron_consumption_power_l1');
const reseau = nombre(st, 'sensor.victron_grid_power_l1'); // > 0 = import
const soc = nombre(st, 'sensor.victron_battery_soc');
const courant = nombre(st, 'sensor.victron_battery_current'); // > 0 = charge
const edf = st.get('switch.edf_general_ok_switch')?.state ?? null;
const modes = MPPT_MODES
.map((id) => String(st.get(id)?.state ?? '').trim().toLowerCase())
.filter((m) => m !== '' && m !== 'unavailable' && m !== 'unknown');
const limites = modes.filter((m) => m.includes('limited')).length;
const actifs = modes.filter((m) => m === 'mppt_active').length;
const importW = reseau === null ? null : Math.max(0, reseau);
const attributs = {
pv_w: pv, conso_w: conso, import_w: importW, soc_pct: soc,
courant_batterie_a: courant, contacteur_edf: edf,
mppt_lisibles: modes.length, mppt_bridés: limites, mppt_a_fond: actifs,
part_reseau_pct: importW !== null && conso ? Math.round((100 * importW) / conso) : null,
};
const verdict = (etat, explication) => ({ etat, explication, ...attributs });
if (pv === null || conso === null || reseau === null) {
return verdict('indetermine', 'Mesure PV, consommation ou reseau indisponible : ne pas conclure.');
}
if (limites === 0 && pv < PV_NUIT_W) {
return verdict('pas_de_production', 'Pas de production solaire (nuit ou aube) : rien a diagnostiquer.');
}
if (modes.length === 0) {
return verdict('indetermine', 'Aucun mode de chargeur lisible : impossible de distinguer bridage et baisse.');
}
if (limites > 0) {
if (importW > IMPORT_BRUIT_W) {
return verdict('substitution_edf',
`ANOMALIE : ${limites} chargeur(s) bride(s) alors que le reseau importe ${Math.round(importW)} W ` +
`— EDF fournit ce que le solaire pourrait donner. A signaler.`);
}
return verdict('bridage_normal',
`${limites} chargeur(s) se retiennent et le reseau n'importe rien : bridage normal d'une ` +
`installation sans injection. De la ressource solaire reste disponible pour une charge de plus.`);
}
if (importW > IMPORT_BRUIT_W) {
return verdict('reseau_en_appoint',
`Tous les chargeurs donnent leur maximum ; le reseau complete (${Math.round(importW)} W). ` +
`Le solaire n'est pas gache.`);
}
return verdict('ressource_limitee',
'Tous les chargeurs donnent leur maximum : la production est limitee par le soleil, rien n\'est gache.');
}
// ---------------------------------------------------------------------
// G13b — detail cellule : une valeur perimee ne doit plus etre VISIBLE
// ---------------------------------------------------------------------
// Le releve serie tourne toutes les 10 min (deploy/releve-cellules/compose.yaml,
// --suivi 600). Perime au-dela de 3 pas, comme la garde anti-blocage de
// pylontech-console.mjs (§75) : au-dela, le lecteur est considere fige.
// Perime => ni ecart ni module publies : l'agent ne peut pas citer ce qu'il ne
// voit pas.
export const RELEVE_PAS_MIN = 10;
export const RELEVE_PERIME_MIN = 3 * RELEVE_PAS_MIN;
export function etatCellules(r) {
if (!r || r.age_min === null || r.age_min === undefined) {
return { etat: 'muet', attributs: { statut: 'muet', explication: 'Aucun releve cellule en base.' } };
}
const age = Number(r.age_min);
if (age > RELEVE_PERIME_MIN) {
return {
etat: 'perime',
attributs: {
statut: 'perime', age_releve_min: age, seuil_perime_min: RELEVE_PERIME_MIN,
explication: `Releve cellule muet depuis ${age} min : aucune valeur actuelle a donner.`,
},
};
}
return {
etat: r.spread_max === null ? 'muet' : Number(r.spread_max),
attributs: {
statut: r.spread_max === null ? 'muet' : 'frais',
age_releve_min: age, seuil_perime_min: RELEVE_PERIME_MIN,
module_le_plus_sensible: r.weakest || null,
},
};
}

View File

@@ -30,6 +30,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import pg from 'pg';
import { texteRapport, envoyer } from './rapport-nuit.mjs';
import { diagnosticPv, etatCellules } from './diagnostics-agent.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ONCE = process.argv.includes('--once');
@@ -1810,8 +1811,41 @@ async function ecrireHelperNombre(entite, valeur) {
// source vivante. On expose l'ECART max du pack, le module extreme, et l'AGE du
// dernier relevé — c'est l'age qui permet a l'agent d'appliquer la meme
// discipline que pour le gel GX (frais -> il le donne, vieux -> il dit muet).
// Publie un capteur calcule (etat + attributs) via l'API d'etats de HA. Il
// disparait a un redemarrage de HA jusqu'au cycle suivant (<= 1 min) : l'agent
// lit alors une entite absente, donc « muet », jamais une valeur fausse.
async function publierCapteur(entite, etat, attributs) {
if (DRY) return;
const r = await fetch(`${HA_URL}/api/states/${entite}`, {
method: 'POST',
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ state: String(etat), attributes: attributs }),
signal: AbortSignal.timeout(8000),
});
if (!r.ok) throw new Error(`${entite} : HTTP ${r.status}`);
}
// Verdict PV pour l'agent vocal (G5 du banc) : bridage normal / substitution
// EDF / ressource limitee, calcule par tools/diagnostics-agent.mjs et teste.
// Hors base : ne depend que des etats HA, donc survit a une panne PostgreSQL.
async function publierDiagnosticPv(st) {
const d = diagnosticPv(st);
await publierCapteur('sensor.evcs_pv_diagnostic', d.etat, {
friendly_name: 'EVCS — diagnostic production PV', icon: 'mdi:solar-power-variant', ...d,
calcule_a: new Date().toISOString(),
});
return 1;
}
async function publierCellulesHelpers(client) {
if (DRY || !client) return 0;
if (DRY) return 0;
// Base injoignable : on publie « muet » plutot que de laisser au capteur son
// dernier etat « frais », qui vieillirait sans que rien ne le dise.
const muet = (cause) => publierCapteur('sensor.evcs_cellules', 'muet', {
friendly_name: 'EVCS cellules — ecart max du pack', icon: 'mdi:battery-heart-variant',
statut: 'muet', explication: `Detail cellule indisponible (${cause}).`,
});
if (!client) { await muet('base injoignable'); return 0; }
const { rows: [r] } = await client.query(
`WITH mx AS (SELECT max(ts) AS t FROM cellule),
recent AS (SELECT DISTINCT ON (module) module, ts FROM cellule
@@ -1821,7 +1855,16 @@ async function publierCellulesHelpers(client) {
GROUP BY c.module)
SELECT (SELECT max(ecart) FROM spreads) AS spread_max,
(SELECT module FROM spreads ORDER BY ecart DESC NULLS LAST LIMIT 1) AS weakest,
(SELECT round(EXTRACT(EPOCH FROM (now() - t)) / 60)::int FROM mx) AS age_min`);
(SELECT round(EXTRACT(EPOCH FROM (now() - t)) / 60)::int FROM mx) AS age_min`)
.catch(async (e) => { await muet('base injoignable').catch(() => {}); throw e; });
// Capteur unique pour l'agent (25/09) : releve perime => ni ecart ni module
// visibles (G13b du banc OpenClaw). Publie avant le test de table vide, pour
// que « muet » soit dit aussi quand il n'y a rien.
const cel = etatCellules(r);
await publierCapteur('sensor.evcs_cellules', cel.etat, {
friendly_name: 'EVCS cellules — ecart max du pack', unit_of_measurement: typeof cel.etat === 'number' ? 'mV' : undefined,
icon: 'mdi:battery-heart-variant', ...cel.attributs,
});
if (!r || r.age_min === null) return 0; // table cellule vide : rien a publier
await ecrireHelperNombre('input_number.evcs_cell_spread_max_mv', r.spread_max === null ? null : Number(r.spread_max));
await ecrireHelperNombre('input_number.evcs_cell_releve_age_min', Number(r.age_min));
@@ -1862,6 +1905,9 @@ async function cycle(client) {
ordre: await appliquerOrdreAirtria(sql, client, st).catch((e) => {
console.error(`ordre airtria en echec : ${e.message}`); return 0;
}),
pvDiagnostic: DRY ? 0 : await publierDiagnosticPv(st).catch((e) => {
console.error(`diagnostic PV en echec : ${e.message}`); return 0;
}),
// 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.
@@ -2001,6 +2047,17 @@ for (;;) {
cyclesEnEchec += 1;
dernierEchec = String(e.message).slice(0, 200);
console.error(`${new Date().toISOString()} cycle en echec : ${e.message}`);
// Les deux capteurs de l'agent vocal ne doivent pas rester figes sur leur
// dernier etat quand le cycle tombe (base injoignable : cycle() n'est meme
// pas appele). Le diagnostic PV ne depend que de HA ; le detail cellule, lui,
// passe a « muet ».
if (!DRY) {
await etats().then(publierDiagnosticPv).catch(() => {});
await publierCapteur('sensor.evcs_cellules', 'muet', {
friendly_name: 'EVCS cellules — ecart max du pack', icon: 'mdi:battery-heart-variant',
statut: 'muet', explication: `Detail cellule indisponible (${dernierEchec}).`,
}).catch(() => {});
}
}
// HORS du try/catch ci-dessus, et volontairement : c'est ce qui fait que la