Files
EVCS/tests/diagnostics-agent/run.mjs
Tallagrand Thierry 63d93667d1 evcs_import_jour_kwh : le compteur du contacteur EDF fait foi, le MultiPlus en recoupement et en repli
Premier releve live (26/09 12:19) : compteur EDF 0,8 = VRM, delta des
compteurs AC-in du MultiPlus 0,7 (0,6 + 0,1, pas apparent de 0,1 kWh).
Repli sur le MultiPlus si le compteur EDF est muet ou remis a zero.
Tests diagnostics 55/55, deux mutations qui mordent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xd7aRDEChdShwsECohnDN
2026-09-26 12:32:05 +02:00

255 lines
16 KiB
JavaScript

// 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 fs from 'node:fs';
import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, COMPTEURS_IMPORT, COMPTEUR_EDF, 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 }) {
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}`);
}
// --- Production du jour (G1, G9) ---------------------------------------
const MIDI = new Date('2026-09-24T12:00:00Z'); // 14:00 a Paris
const NUIT = new Date('2026-09-24T22:40:00Z'); // 00:40 a Paris le 25
function rend(v1, v2, v3, change = '2026-09-24T11:59:00Z') {
const m = new Map();
[['sensor.airtria_mppt1_yield_today', v1], ['sensor.mppt_450_200_hq2449yr9pt_yield_today', v2],
['sensor.v1_yield_today', v3]].forEach(([id, v]) => {
const [val, lc] = Array.isArray(v) ? v : [v, change];
m.set(id, { state: val === null ? 'unavailable' : String(val), last_changed: lc });
});
// un sous-tracker present ne doit JAMAIS etre additionne
m.set('sensor.mppt_450_200_hq2449yr9pt_tracker_solar_1_yield_today', { state: '20.0', last_changed: change });
return m;
}
const prod = [
['G1 somme des trois (45,1 du 24/09), sous-tracker ignore', rend(9.45, 32.71, 2.97), MIDI,
(r) => r.etat === 45.1 && r.attributs.statut === 'complet'],
['G9 un chargeur unavailable -> partiel, somme du reste, muet nomme', rend(9.45, 32.71, null), MIDI,
(r) => r.etat === 42.2 && r.attributs.statut === 'partiel' && r.attributs.chargeurs_muets.join() === 'v1'],
['tous muets -> indisponible', rend(null, null, null), MIDI, (r) => r.etat === 'indisponible'],
['00:40 : un chargeur pas encore remis a zero (valeur d hier) -> compte 0',
rend(['0.0', '2026-09-24T22:37:00Z'], ['32.71', '2026-09-24T17:30:00Z'], ['0.0', '2026-09-24T22:37:00Z']), NUIT,
(r) => r.etat === 0 && r.attributs.pas_encore_remis_a_zero.join() === 'mppt_450_200'],
['heure d ete : 23:30 Paris le 24 = encore aujourd hui', rend(9.45, 32.71, 2.97, '2026-09-24T17:30:00Z'),
new Date('2026-09-24T21:30:00Z'), (r) => r.etat === 45.1],
];
for (const [nom, etats, t, test] of prod) {
const ok = test(productionJour(etats, t));
if (!ok) { ko++; console.log(JSON.stringify(productionJour(etats, t))); }
console.log(`${ok ? 'OK ' : 'KO '} ${nom}`);
}
// --- Charge voiture : RELAIS du controleur (G6, G7) --------------------
function borne({ branche = 'on', p = 0, source = 'Solaire', raison = 'Excedent negatif | Profil=x', pv = 2000, conso = 3854, modes = ACT }) {
const m = st({ pv, conso, reseau: 0, modes });
const put = (id, v) => m.set(id, { state: String(v) });
put('binary_sensor.victron_evcs_vehicle_connected', branche);
put('sensor.victron_evcs_ac_power', p);
put('input_text.victron_evcs_source_mode', source);
put('input_text.victron_evcs_surplus_reason', raison);
put('input_select.victron_evcs_strategy', 'auto_solar_plus');
return m;
}
const charge = [
['G6 branchee, pas en charge -> en_attente + raison du controleur, excedent explicatif',
borne({ source: 'Bloqué', raison: 'Excedent insuffisant | Profil=outside_window' }),
(r) => r.etat === 'en_attente' && r.attributs.raison_controleur === 'Excedent insuffisant' && r.attributs.excedent_w === -1854],
['en charge sur source solaire -> en_charge_solaire', borne({ p: 3600, source: 'Solaire' }), (r) => r.etat === 'en_charge_solaire'],
['en charge source reseau -> en_charge_reseau', borne({ p: 3600, source: 'Réseau HC autorisé' }), (r) => r.etat === 'en_charge_reseau'],
// Incident du 25/09 01:25-06:00 : JEEP sur batterie la nuit, publie « solaire ».
['INCIDENT 25/09 : assistance batterie nuit -> en_charge_batterie, JAMAIS solaire',
borne({ p: 1198, source: 'Assistance batterie nuit', pv: 0, conso: 900, modes: 'off' }),
(r) => r.etat === 'en_charge_batterie' && !/solaire/i.test(r.attributs.explication.split('(')[0])],
['sonde hybride -> en_charge_sonde', borne({ p: 2300, source: 'Probe hybride' }), (r) => r.etat === 'en_charge_sonde'],
['potentiel solaire -> en_charge_solaire', borne({ p: 2300, source: 'Potentiel solaire' }), (r) => r.etat === 'en_charge_solaire'],
['reseau force -> en_charge_reseau', borne({ p: 3600, source: 'Réseau forcé' }), (r) => r.etat === 'en_charge_reseau'],
['libelle inconnu -> en_charge sans origine devinee', borne({ p: 3600, source: 'nouveau_mode' }), (r) => r.etat === 'en_charge'],
['non branchee -> non_branchee', borne({ branche: 'off' }), (r) => r.etat === 'non_branchee'],
['G7 excedent positif et bride, mais controleur bloque -> en_attente (on RELAIE, on ne rejuge pas)',
borne({ pv: 5000, conso: 1500, modes: LIM, source: 'Bloqué', raison: 'Vehicule plein' }),
(r) => r.etat === 'en_attente' && r.attributs.diagnostic_pv === 'bridage_normal'],
['borne illisible -> indetermine', (() => { const m = borne({}); m.set('sensor.victron_evcs_ac_power', { state: 'unavailable' }); return m; })(),
(r) => r.etat === 'indetermine'],
];
for (const [nom, etats, test] of charge) {
const ok = test(chargeSolaire(etats));
if (!ok) { ko++; console.log(JSON.stringify(chargeSolaire(etats))); }
console.log(`${ok ? 'OK ' : 'KO '} ${nom}`);
}
// Chaque libelle publie par le controleur doit avoir une origine explicite :
// lu dans SON code, pour qu'un libelle ajoute la-bas fasse tomber ce test.
const srcCtrl = fs.readFileSync(new URL('../../src/nodes/HA_SERVICE_CALLS_OUTPUT.js', import.meta.url), 'utf8');
const bloc = srcCtrl.slice(srcCtrl.indexOf('const sourceModeLabel ='), srcCtrl.indexOf('context.sourceMode;', srcCtrl.indexOf('const sourceModeLabel =')));
const libelles = [...bloc.matchAll(/\? '([^']+)'/g)].map((m) => m[1]).filter((l) => l !== 'Bloqué');
const nonCouverts = libelles.filter((l) => !ORIGINE_PAR_SOURCE[l]);
const okLib = libelles.length >= 6 && nonCouverts.length === 0;
if (!okLib) ko++;
console.log(`${okLib ? 'OK ' : 'KO '} les ${libelles.length} libelles de source du controleur ont une origine${okLib ? '' : ` (manquent : ${nonCouverts.join(', ') || 'table introuvable'})`}`);
// --- Bilan de la nuit : la nuit reelle du 24 au 25/09 (incident de 11:49) ---
const nuit = {
debut: new Date('2026-09-24T16:00:00Z'), fin: new Date('2026-09-25T08:00:00Z'),
dateLue: 'vendredi 25 septembre',
fermetures: [{ ts: new Date('2026-09-25T04:01:00Z') }],
e: { importe: 2.71, voiture: 7.96 },
b: { depart: 97, bas: 31, heure_bas: new Date('2026-09-25T07:16:00Z'), maintenant: 32 },
sessions: [{ debut: new Date('2026-09-24T23:25:00Z'), fin: new Date('2026-09-25T04:42:00Z'), terminee: true,
vehicule: 'JEEP', energie_kwh: 7.96, energie_compteur_kwh: 7.9, energie_reseau_kwh: 2.2, source_mode: 'Assistance batterie nuit' }],
};
const bn = [
['INCIDENT 11:49 : la session terminee est bien visible, 7,9 kWh dont 2,2 reseau, 01:25 -> 06:42',
bilanNuit(nuit, 'x', new Date('2026-09-25T09:49:00Z')),
(r) => r.etat === 'complet' && r.attributs.vehicules[0].energie_kwh === 7.9 && r.attributs.vehicules[0].dont_reseau_kwh === 2.2
&& r.attributs.vehicules[0].debut === '01:25' && r.attributs.vehicules[0].fin === '06:42'],
['aucun SOC voiture publie ; le 35 % n existe nulle part', bilanNuit(nuit, 'x', new Date('2026-09-25T09:49:00Z')),
(r) => !JSON.stringify(r).includes('35') && /aucun capteur/.test(r.attributs.soc_voiture)],
['reseau : fermeture 06:01, import 2,7', bilanNuit(nuit, 'x', new Date('2026-09-25T09:49:00Z')),
(r) => r.attributs.reseau_fermetures.join() === '06:01' && r.attributs.reseau_importe_kwh === 2.7],
['avant 10:00 -> en_cours, session ouverte sans heure de fin', bilanNuit({ ...nuit,
sessions: [{ ...nuit.sessions[0], terminee: false }] }, 'x', new Date('2026-09-25T03:00:00Z')),
(r) => r.etat === 'en_cours' && r.attributs.vehicules[0].fin === null],
['nuit sans charge -> liste vide', bilanNuit({ ...nuit, sessions: [] }, 'x', new Date('2026-09-25T09:49:00Z')),
(r) => r.attributs.vehicules.length === 0],
];
for (const [nom, r, test] of bn) {
const ok = test(r);
if (!ok) { ko++; console.log(JSON.stringify(r)); }
console.log(`${ok ? 'OK ' : 'KO '} ${nom}`);
}
// 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}`);
}
// Import depuis minuit. Releve reel du 26/09 12:19 : EDF 0,8 (= VRM), MultiPlus 0,6 + 0,1.
const [OUT, INV] = COMPTEURS_IMPORT;
const mn = { [OUT]: 1200.0, [INV]: 845.0, [COMPTEUR_EDF]: 3082.0 };
const ok26 = { [OUT]: 1200.6, [INV]: 845.1, [COMPTEUR_EDF]: 3082.83 };
const imp = [
['26/09 : le compteur EDF fait foi -> 0,8 comme VRM, MultiPlus 0,7 en recoupement',
importJour(ok26, mn), (r) => r.etat === 0.8 && r.attributs.source === 'compteur_edf' && r.attributs.victron_kwh === 0.7],
['journee sans reseau -> 0, pas indisponible',
importJour({ [OUT]: 1200, [INV]: 845, [COMPTEUR_EDF]: 3082 }, mn), (r) => r.etat === 0 && r.attributs.statut === 'complet'],
['compteur EDF remis a zero -> repli MultiPlus, jamais un import negatif',
importJour({ ...ok26, [COMPTEUR_EDF]: 0.4 }, mn), (r) => r.etat === 0.7 && r.attributs.statut === 'repli_victron'],
['compteur EDF muet -> repli MultiPlus, dit « environ »',
importJour({ ...ok26, [COMPTEUR_EDF]: null }, mn), (r) => r.etat === 0.7 && /Environ/.test(r.attributs.explication)],
['EDF muet ET un MultiPlus remis a zero -> indisponible',
importJour({ [OUT]: 3.2, [INV]: 845.1, [COMPTEUR_EDF]: null }, mn), (r) => r.etat === 'indisponible'],
['EDF muet ET un MultiPlus muet -> indisponible, pas de total partiel',
importJour({ [OUT]: 1200.6, [INV]: null, [COMPTEUR_EDF]: null }, mn), (r) => r.etat === 'indisponible'],
['valeurs de minuit inconnues -> indisponible',
importJour(ok26, {}), (r) => r.etat === 'indisponible'],
];
for (const [nom, r, test] of imp) {
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 + imp.length;
console.log(`\n${n - ko}/${n}`);
process.exit(ko ? 1 : 0);