diff --git a/tests/diagnostics-agent/run.mjs b/tests/diagnostics-agent/run.mjs index 975e8e9..2a65118 100644 --- a/tests/diagnostics-agent/run.mjs +++ b/tests/diagnostics-agent/run.mjs @@ -2,7 +2,7 @@ // Les cas PV reprennent les gammes du banc OpenClaw (HANDOFF §77) : G3, G4, G5, G7. // node tests/diagnostics-agent/run.mjs import fs from 'node:fs'; -import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, MPPT_MODES, ORIGINE_PAR_SOURCE } from '../../tools/diagnostics-agent.mjs'; +import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, MPPT_MODES, ORIGINE_PAR_SOURCE } 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 }) { @@ -177,6 +177,52 @@ for (const [nom, r, test] of bn) { console.log(`${ok ? 'OK ' : 'KO '} ${nom}`); } -const n = cas.length + cel.length + prod.length + charge.length + 1 + bn.length; +// Raccordement reseau. Cas de reference : 26/09 11:38, contacteur ouvert a 07:24. +const T = new Date('2026-09-26T09:38:00Z'); +function rs({ sw = 'off', swA = '2026-09-26T05:24:03Z', ac = 0, gp = 0, gpA = '2026-09-26T05:24:05Z', settle }) { + const m = new Map(); + const put = (id, v, at) => m.set(id, { entity_id: id, state: v === null ? 'unavailable' : String(v), last_changed: at }); + put('switch.edf_general_ok_switch', sw, swA); + put('sensor.victron_ac_in1_available', ac, swA); + put('sensor.airtria_mpii_v1_active_ac_input', ac ? 'ac_input_1' : 'not_connected', swA); + put('sensor.victron_grid_power_l1', gp, gpA); + if (settle !== undefined) put('input_number.evcs_grid_settle_seconds', settle, swA); + return m; +} +const res = [ + ['INCIDENT 26/09 : contacteur ouvert a 07:24, 0 W inchange depuis 4 h -> non_raccorde, PAS muet', + etatReseau(rs({}), T), (r) => r.etat === 'non_raccorde' && r.attributs.import_w === 0 && r.attributs.depuis_heure === '07:24'], + ['contacteur ferme, reseau vu, mesure fraiche -> raccorde avec import', + etatReseau(rs({ sw: 'on', swA: '2026-09-26T09:00:00Z', ac: 1, gp: 850, gpA: '2026-09-26T09:37:50Z' }), T), + (r) => r.etat === 'raccorde' && r.attributs.import_w === 850], + ['contacteur ferme depuis 38 min, MultiPlus ne voit pas le reseau -> mesure_muette', + etatReseau(rs({ sw: 'on', swA: '2026-09-26T09:00:00Z', ac: 0 }), T), + (r) => r.etat === 'mesure_muette' && r.attributs.import_w === null], + ['contacteur ferme, reseau vu, puissance inchangee depuis 30 min -> mesure_muette', + etatReseau(rs({ sw: 'on', swA: '2026-09-26T09:00:00Z', ac: 1, gp: 0, gpA: '2026-09-26T09:08:00Z' }), T), + (r) => r.etat === 'mesure_muette'], + ['contacteur ferme, puissance unavailable -> mesure_muette', + etatReseau(rs({ sw: 'on', swA: '2026-09-26T09:00:00Z', ac: 1, gp: null }), T), (r) => r.etat === 'mesure_muette'], + ['fermeture il y a 60 s, ac_in pas encore la -> etablissement, pas muet', + etatReseau(rs({ sw: 'on', swA: '2026-09-26T09:37:00Z', ac: 0 }), T), (r) => r.etat === 'etablissement'], + ['helper d etablissement lu : 30 s -> au-dela, ac_in 0 = muet', + etatReseau(rs({ sw: 'on', swA: '2026-09-26T09:37:00Z', ac: 0, settle: 30 }), T), (r) => r.etat === 'mesure_muette'], + ['fermeture il y a 5 min, puissance inchangee depuis : pas encore figee', + etatReseau(rs({ sw: 'on', swA: '2026-09-26T09:33:00Z', ac: 1, gp: 40, gpA: '2026-09-26T09:34:00Z' }), T), + (r) => r.etat === 'raccorde'], + ['contacteur ouvert mais reseau vu -> incoherent', + etatReseau(rs({ ac: 1 }), T), (r) => r.etat === 'incoherent'], + ['contacteur ouvert mais import 900 W -> incoherent', + etatReseau(rs({ gp: 900, gpA: '2026-09-26T09:37:00Z' }), T), (r) => r.etat === 'incoherent'], + ['contacteur unavailable -> indetermine', + etatReseau(rs({ sw: null }), T), (r) => r.etat === 'indetermine'], +]; +for (const [nom, r, test] of res) { + const ok = test(r); + if (!ok) { ko++; console.log(JSON.stringify(r)); } + console.log(`${ok ? 'OK ' : 'KO '} ${nom}`); +} + +const n = cas.length + cel.length + prod.length + charge.length + 1 + bn.length + res.length; console.log(`\n${n - ko}/${n}`); process.exit(ko ? 1 : 0); diff --git a/tools/diagnostics-agent.mjs b/tools/diagnostics-agent.mjs index 563426d..8100301 100644 --- a/tools/diagnostics-agent.mjs +++ b/tools/diagnostics-agent.mjs @@ -318,3 +318,95 @@ export function bilanNuit(d, texte, maintenant = new Date()) { }, }; } + +// --------------------------------------------------------------------- +// Raccordement reseau — un 0 « fige » n'est PAS une panne quand EDF est ouvert +// --------------------------------------------------------------------- +// Incident du 26/09 : l'agent a annonce « capteur reseau bloque a 7h24 ». C'etait +// la fin des heures creuses : contacteur ouvert, AC-IN non raccorde, import +// reellement nul. HA ne date une valeur qu'a son CHANGEMENT : un 0 legitime garde +// le last_changed de l'ouverture. L'age de la mesure seul donnerait donc une +// fausse panne tous les matins. Le seul signal d'une vraie panne de mesure est la +// CONTRADICTION commande/mesure (MANUEL §0) : contacteur commande ferme alors que +// le MultiPlus ne voit pas le reseau, ou que la puissance ne publie plus. +// +// Fenetre d'etablissement apres fermeture : meme helper que le workflow +// d'hysteresis (HANDOFF §29), 180 s par defaut. +export const ETABLISSEMENT_DEFAUT_S = 180; +// Contacteur ferme, la puissance reseau bouge en permanence (bruit de l'ESS a +// consigne). Inchangee pendant ce delai => elle ne publie plus. Valeur +// PROVISOIRE, a confronter a la cadence reelle de sensor.victron_grid_power_l1. +export const MESURE_FIGEE_MIN = 10; + +function ageMin(s, maintenant) { + const t = s?.last_changed ? Date.parse(s.last_changed) : NaN; + return Number.isFinite(t) ? Math.round((maintenant - t) / 60000) : null; +} + +function heureParis(iso) { + if (!iso) return null; + return new Date(iso).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }); +} + +export function etatReseau(st, maintenant = new Date()) { + const sw = st.get('switch.edf_general_ok_switch'); + const contacteur = sw?.state ?? null; + const acIn = nombre(st, 'sensor.victron_ac_in1_available'); // 1 = reseau vu + const entree = st.get('sensor.airtria_mpii_v1_active_ac_input')?.state ?? null; + const gp = st.get('sensor.victron_grid_power_l1'); + const reseau = nombre(st, 'sensor.victron_grid_power_l1'); + const etablissementS = nombre(st, 'input_number.evcs_grid_settle_seconds') ?? ETABLISSEMENT_DEFAUT_S; + const depuisMin = ageMin(sw, maintenant); + const mesureAgeMin = ageMin(gp, maintenant); + + const attributs = { + contacteur_edf: contacteur, + depuis: sw?.last_changed ?? null, + depuis_heure: heureParis(sw?.last_changed), + ac_in_disponible: acIn, + entree_active: entree, + import_w: reseau === null ? null : Math.max(0, Math.round(reseau)), + mesure_age_min: mesureAgeMin, + seuil_mesure_figee_min: MESURE_FIGEE_MIN, + }; + const verdict = (etat, explication, extra = {}) => ({ etat, attributs: { ...attributs, ...extra, explication } }); + + if (contacteur !== 'on' && contacteur !== 'off') { + return verdict('indetermine', 'Etat du contacteur EDF inconnu : ne rien affirmer sur le reseau.'); + } + + if (contacteur === 'off') { + // Reseau vu ou import mesure alors que le contacteur est commande ouvert. + if (acIn === 1 || (reseau !== null && reseau > IMPORT_BRUIT_W)) { + return verdict('incoherent', + `ANOMALIE : contacteur EDF commande ouvert, mais le MultiPlus voit le reseau ` + + `(ac_in ${acIn}, ${attributs.import_w ?? '?'} W). A signaler.`); + } + return verdict('non_raccorde', + `Reseau EDF non raccorde depuis ${attributs.depuis_heure ?? '?'} (contacteur ouvert) : ` + + `import nul, ce n'est pas une panne de capteur.`, { import_w: 0 }); + } + + // Contacteur ferme. + if (depuisMin !== null && depuisMin * 60 < etablissementS) { + return verdict('etablissement', + `Contacteur EDF ferme a ${attributs.depuis_heure}, reseau en cours d'etablissement.`); + } + if (acIn === 0) { + return verdict('mesure_muette', + `ANOMALIE : contacteur EDF ferme depuis ${attributs.depuis_heure ?? '?'} mais le MultiPlus ` + + `ne voit pas le reseau (coupure EDF ou defaut de lecture). Import inconnu.`, { import_w: null }); + } + if (reseau === null) { + return verdict('mesure_muette', + 'Contacteur EDF ferme mais la puissance reseau ne publie pas : import inconnu.', { import_w: null }); + } + if (mesureAgeMin !== null && mesureAgeMin > MESURE_FIGEE_MIN + && (depuisMin === null || depuisMin > MESURE_FIGEE_MIN)) { + return verdict('mesure_muette', + `Contacteur EDF ferme mais la puissance reseau n'a pas bouge depuis ${mesureAgeMin} min : ` + + `mesure figee, import inconnu.`, { import_w: null }); + } + return verdict('raccorde', + `Reseau EDF raccorde depuis ${attributs.depuis_heure ?? '?'}, import ${attributs.import_w} W.`); +} diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index a536649..c7e5a68 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -30,7 +30,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import pg from 'pg'; import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs'; -import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit } from './diagnostics-agent.mjs'; +import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau } from './diagnostics-agent.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ONCE = process.argv.includes('--once'); @@ -1853,6 +1853,11 @@ async function publierDiagnosticPv(st) { await publierCapteur('sensor.evcs_charge_solaire', ch.etat, { friendly_name: 'EVCS — charge voiture (decision du controleur)', icon: 'mdi:ev-station', ...ch.attributs, }); + // Raccordement reseau : contacteur ouvert = import nul, pas une panne (26/09). + const rr = etatReseau(st); + await publierCapteur('sensor.evcs_reseau', rr.etat, { + friendly_name: 'EVCS — raccordement reseau EDF', icon: 'mdi:transmission-tower', ...rr.attributs, + }); return 1; }