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

View File

@@ -523,3 +523,31 @@ pack 2). SPARK bascule l'agent dessus (fiche + gamme G13 live).
--- ---
*Document mis a jour le : 25/09/2026 — détail cellule publié en helpers HA (source série vivante), agent vocal câblé* *Document mis a jour le : 25/09/2026 — détail cellule publié en helpers HA (source série vivante), agent vocal câblé*
## 79. Session du 25/09/2026 — Les jugements sortent de la fiche de l'agent vocal
SPARK a restructure l'AGENTS.md d'OpenClaw (28 247 / 30 000 car, plafond par
fichier du gateway) : permutation pure des sections, l'energie en tete (dans les
45 % gardes mot pour mot si le fichier tronque), md5 des 10 sections identiques.
Le code du gateway ne charge que AGENTS.md et USER.md : pas de split possible.
⚠️ Decouverte : la fiche est sur le fil du rasoir. 96 car ajoutes dans le bloc
CELLULE (pour G13b) ont fait tomber G5 (substitution) de 7/7 a 1/7, sans lien
semantique. Plutot que de rediger mieux, les deux jugements sont sortis du
modele et calcules par le service d'historisation (`tools/diagnostics-agent.mjs`,
fonctions pures, `node tests/diagnostics-agent/run.mjs` 15/15, deux mutations
qui mordent) :
- `sensor.evcs_pv_diagnostic` : `bridage_normal` / `substitution_edf` /
`ressource_limitee` / `reseau_en_appoint` / `pas_de_production` /
`indetermine`, avec explication et grandeurs en attributs. L'import est teste
AVANT de conclure au bridage, par l'ordre du code (regle du 31/08). Bruit
compteur 100 W.
- `sensor.evcs_cellules` : ecart max (mV) si releve frais ; `perime` au-dela de
30 min (3 pas de 10 min, comme la garde anti-blocage §75) SANS ecart ni module
en attribut ; `muet` si base injoignable ou cycle en echec. L'agent ne peut
plus citer une valeur perimee : il ne la voit pas.
Les deux sont republies meme quand le cycle echoue. Les anciens helpers
`evcs_cell_*` restent ecrits le temps que SPARK bascule la fiche, puis a retirer.
---
*Document mis a jour le : 25/09/2026 — diagnostics PV et cellule calcules cote service, publies pour l'agent vocal*

View File

@@ -0,0 +1,69 @@
// Tests des diagnostics publies pour l'agent vocal (tools/diagnostics-agent.mjs).
// Les cas PV reprennent les gammes du banc OpenClaw (HANDOFF §77) : G3, G4, G5, G7.
// node tests/diagnostics-agent/run.mjs
import { diagnosticPv, etatCellules, MPPT_MODES } from '../../tools/diagnostics-agent.mjs';
const LIM = 'voltage_current_limited', ACT = 'mppt_active';
function st({ pv, conso, reseau, soc = 80, courant = 0, edf = 'off', modes }) {
const m = new Map();
const put = (id, v) => m.set(id, { entity_id: id, state: v === null ? 'unavailable' : String(v) });
put('sensor.victron_pv_power_system', pv);
put('sensor.victron_consumption_power_l1', conso);
put('sensor.victron_grid_power_l1', reseau);
put('sensor.victron_battery_soc', soc);
put('sensor.victron_battery_current', courant);
put('switch.edf_general_ok_switch', edf);
const liste = Array.isArray(modes) ? modes : MPPT_MODES.map(() => modes);
MPPT_MODES.forEach((id, i) => put(id, liste[i]));
return m;
}
const cas = [
['G3 batterie pleine, 7 brides, import nul -> bridage normal',
st({ pv: 1570, conso: 1500, reseau: 0, soc: 97, courant: 1, modes: LIM }), 'bridage_normal'],
['G4 vraie baisse : 7 a fond, SOC 69, +25 A -> ressource limitee',
st({ pv: 1600, conso: 400, reseau: 0, soc: 69, courant: 25, modes: ACT }), 'ressource_limitee'],
['G5 brides MAIS import ~ conso -> substitution',
st({ pv: 300, conso: 1200, reseau: 1150, soc: 97, courant: 0, edf: 'on', modes: LIM }), 'substitution_edf'],
['G7 un seul bride, import nul -> bridage normal (ressource dispo)',
st({ pv: 5200, conso: 2000, reseau: 0, soc: 92, courant: 60, modes: [LIM, ACT, ACT, ACT, ACT, ACT, ACT] }), 'bridage_normal'],
['bruit du compteur (60 W) contacteur ferme : pas une substitution',
st({ pv: 1500, conso: 1400, reseau: 60, soc: 96, edf: 'on', modes: LIM }), 'bridage_normal'],
['un seul bride suffit a signer la substitution si le reseau importe',
st({ pv: 3000, conso: 3500, reseau: 900, edf: 'on', modes: [LIM, ACT, ACT, ACT, ACT, ACT, ACT] }), 'substitution_edf'],
['tous a fond + import -> reseau en appoint, pas une anomalie',
st({ pv: 2000, conso: 3500, reseau: 1500, edf: 'on', modes: ACT }), 'reseau_en_appoint'],
['nuit : PV 0, tous off -> pas de production',
st({ pv: 0, conso: 600, reseau: 0, modes: 'off' }), 'pas_de_production'],
['nuit + reseau ferme : toujours pas de substitution',
st({ pv: 0, conso: 600, reseau: 600, edf: 'on', modes: 'off' }), 'pas_de_production'],
['reseau indisponible -> indetermine (ne pas conclure)',
st({ pv: 1500, conso: 1400, reseau: null, modes: LIM }), 'indetermine'],
['modes illisibles en journee -> indetermine',
st({ pv: 1500, conso: 1400, reseau: 0, modes: 'unavailable' }), 'indetermine'],
];
let ko = 0;
for (const [nom, etats, attendu] of cas) {
const r = diagnosticPv(etats);
const ok = r.etat === attendu;
if (!ok) ko++;
console.log(`${ok ? 'OK ' : 'KO '} ${nom}${ok ? '' : ` (obtenu ${r.etat})`}`);
}
const cel = [
['releve frais -> ecart et module publies', { spread_max: 21, weakest: 'K220803C30160323', age_min: 1 },
(r) => r.etat === 21 && r.attributs.module_le_plus_sensible === 'K220803C30160323'],
['releve a 30 min (limite) -> encore frais', { spread_max: 5, weakest: 'Y1', age_min: 30 }, (r) => r.etat === 5],
['G13b releve a 42 min -> perime, AUCUNE valeur visible', { spread_max: 21, weakest: 'K220803C30160323', age_min: 42 },
(r) => r.etat === 'perime' && !('module_le_plus_sensible' in r.attributs)
&& !JSON.stringify(r).includes('21,') && !JSON.stringify(r).includes('K2208')],
['table vide -> muet', { spread_max: null, weakest: null, age_min: null }, (r) => r.etat === 'muet'],
];
for (const [nom, row, test] of cel) {
const ok = test(etatCellules(row));
if (!ok) ko++;
console.log(`${ok ? 'OK ' : 'KO '} ${nom}`);
}
console.log(`\n${cas.length + cel.length - ko}/${cas.length + cel.length}`);
process.exit(ko ? 1 : 0);

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 { fileURLToPath } from 'node:url';
import pg from 'pg'; import pg from 'pg';
import { texteRapport, envoyer } from './rapport-nuit.mjs'; 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 ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ONCE = process.argv.includes('--once'); 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 // 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 // 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). // 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) { 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( const { rows: [r] } = await client.query(
`WITH mx AS (SELECT max(ts) AS t FROM cellule), `WITH mx AS (SELECT max(ts) AS t FROM cellule),
recent AS (SELECT DISTINCT ON (module) module, ts FROM cellule recent AS (SELECT DISTINCT ON (module) module, ts FROM cellule
@@ -1821,7 +1855,16 @@ async function publierCellulesHelpers(client) {
GROUP BY c.module) GROUP BY c.module)
SELECT (SELECT max(ecart) FROM spreads) AS spread_max, SELECT (SELECT max(ecart) FROM spreads) AS spread_max,
(SELECT module FROM spreads ORDER BY ecart DESC NULLS LAST LIMIT 1) AS weakest, (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 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_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)); 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) => { ordre: await appliquerOrdreAirtria(sql, client, st).catch((e) => {
console.error(`ordre airtria en echec : ${e.message}`); return 0; 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. // 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 // Hors essai a blanc : l'insertion est massive et idempotente, la simuler
// n'apprendrait rien. // n'apprendrait rien.
@@ -2001,6 +2047,17 @@ for (;;) {
cyclesEnEchec += 1; cyclesEnEchec += 1;
dernierEchec = String(e.message).slice(0, 200); dernierEchec = String(e.message).slice(0, 200);
console.error(`${new Date().toISOString()} cycle en echec : ${e.message}`); 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 // HORS du try/catch ci-dessus, et volontairement : c'est ce qui fait que la