diff --git a/HANDOFF.md b/HANDOFF.md index ba7175a..c1a9c58 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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é* + +## 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* diff --git a/tests/diagnostics-agent/run.mjs b/tests/diagnostics-agent/run.mjs new file mode 100644 index 0000000..c915f22 --- /dev/null +++ b/tests/diagnostics-agent/run.mjs @@ -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); diff --git a/tools/diagnostics-agent.mjs b/tools/diagnostics-agent.mjs new file mode 100644 index 0000000..291f98a --- /dev/null +++ b/tools/diagnostics-agent.mjs @@ -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, + }, + }; +} diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 62bf13b..6d6c5b6 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -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