Files
EVCS/tests/diagnostics-agent/run.mjs
Tallagrand Thierry 5721031e17 Prevision de nuit : « c'est juste » quand seul le profil habituel passe (incident 28/09 21:16)
Trois verdicts au lieu de deux ; « la batterie suffit » exige le requis PRUDENT.
Attribut soc_requis_maintenant_pct (l'ancien nom se lisait comme un SOC du matin).
Tests 73/73, mutation qui mord.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z
2026-09-28 21:28:03 +02:00

374 lines
24 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, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes } 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}`);
}
// --- Consommation du jour : releve reel du 26/09 a 17:11 (VRM ~25 kWh) ------
{
const [R, O] = COMPTEURS_CONSO.map(([, id]) => id);
const mi = { [R]: 9, [O]: 292.4 };
const cj = [
['conso reelle 26/09 17:11 : 0,6 + 24,6 = 25,2 kWh, complet', consoJour({ [R]: 9.6, [O]: 317 }, mi),
(r) => r.etat === 25.2 && r.attributs.statut === 'complet' && r.attributs.detail_kwh.depuis_reseau === 0.6],
['compteur remis a zero -> indisponible, jamais negatif', consoJour({ [R]: 9.6, [O]: 3.1 }, mi),
(r) => r.etat === 'indisponible'],
['un compteur muet -> partiel « au moins »', consoJour({ [R]: null, [O]: 317 }, mi),
(r) => r.etat === 24.6 && r.attributs.statut === 'partiel' && /au moins/.test(r.attributs.explication)],
['minuit inconnu -> indisponible', consoJour({ [R]: 9.6, [O]: 317 }, {}), (r) => r.etat === 'indisponible'],
];
for (const [nom, r, test] of cj) {
const ok = test(r); if (!ok) { ko++; console.log(JSON.stringify(r)); }
console.log(`${ok ? 'OK ' : 'KO '} ${nom}`);
}
globalThis.__nConso = cj.length;
}
// --- Projection du soir : indetermine tant que la prevision est fausse -----
{
const m = new Map([['sensor.victron_battery_soc', { state: '42' }],
['sensor.energy_production_today_remaining', { state: '6.8' }], ['sensor.evcs_prevision_contre_reel', { state: '63' }]]);
const r = projectionSoir(m);
const pj = [
['projection : etat indetermine, JAMAIS un SOC chiffre (incident 27/09 « vers 90 % »)', r,
(x) => x.etat === 'indetermine' && !/\b(9\d|[1-8]\d) ?% au coucher/.test(x.attributs.explication)],
['projection : l explication donne le SOC actuel et l ecart de prevision', r,
(x) => /42 %/.test(x.attributs.explication) && /63 %/.test(x.attributs.explication)],
];
for (const [nom, x, test] of pj) {
const ok = test(x); if (!ok) { ko++; console.log(JSON.stringify(x)); }
console.log(`${ok ? 'OK ' : 'KO '} ${nom}`);
}
globalThis.__nConso = (globalThis.__nConso || 0) + pj.length;
}
// --- Prevision de la nuit : releve reel du 28/09 01:25 ----------------------
{
const base = () => new Map(Object.entries({
'input_text.evcs_balance_status': 'ok', 'input_text.evcs_balance_last_run': '28/09 01:25',
'input_number.evcs_balance_energy_to_cover_kwh': '5.47', 'input_number.evcs_balance_grid_energy_needed_kwh': '5.77',
'input_boolean.evcs_balance_grid_needed': 'on', 'input_text.evcs_balance_crossing_hour': '09 h',
'input_number.evcs_balance_required_soc': '40.2', 'sensor.victron_battery_soc': '21',
'sensor.evcs_reseau': 'raccorde', 'input_boolean.victron_evcs_night_grid_session_committed': 'on',
'binary_sensor.victron_evcs_vehicle_connected': 'off',
// pieges : valeurs internes qui ne doivent JAMAIS apparaitre
'input_number.victron_evcs_projected_end_soc_after_charge': '73.7', 'input_number.evcs_balance_vehicle_surplus_kwh': '26.95',
}).map(([k, v]) => [k, { state: v }]));
const t = new Date('2026-09-27T23:30:00Z'); // 01:30 a Paris
const pn = [
['nuit 28/09 : 5,77 kWh EDF, 5,47 a couvrir, retour 09 h, aucune voiture', previsionNuit(base(), t),
(r) => r.etat === 5.77 && r.attributs.voiture === 'aucune' && /5.77 kWh a prendre sur EDF/.test(r.attributs.explication)],
['jamais 73,7 ni 26,95 dans la prevision (incident 27/09)', previsionNuit(base(), t),
(r) => !/73\.7|26\.95/.test(JSON.stringify(r))],
['bilan perime (> 30 min) -> indisponible', previsionNuit(base(), new Date('2026-09-28T00:30:00Z')),
(r) => r.etat === 'indisponible'],
['session engagee -> « EDF est branche pour la nuit », jamais « la batterie suffit »', previsionNuit(base(), t),
(r) => r.attributs.verdict === 'edf_engage' && /EDF est branche pour la nuit/.test(r.attributs.explication)
&& !/batterie suffit/.test(r.attributs.explication)],
// Releve REEL du 28/09 21:15 (reponse contestee par le proprietaire) :
// requis 57,5 %, SOC 58, prudent 68,5, pas de session. « La batterie suffit,
// sans EDF » etait faux : 0,5 point de marge sur le profil habituel.
['28/09 21:15 : marge 0,5 point, prudent 68,5 > SOC 58 -> « c est juste », decision reprise a 01:24', (() => {
const m = base(); for (const [k, v] of Object.entries({
'input_boolean.evcs_balance_grid_needed': 'off', 'input_number.evcs_balance_required_soc': '57.5',
'input_number.evcs_balance_required_soc_prudent': '68.5', 'sensor.victron_battery_soc': '58',
'input_boolean.victron_evcs_night_grid_session_committed': 'off', 'sensor.evcs_reseau': 'non_raccorde',
'input_number.evcs_balance_energy_to_cover_kwh': '10.64', 'input_datetime.victron_evcs_hc_night_start': '01:24:00',
})) m.set(k, { state: v }); return previsionNuit(m, t); })(),
(r) => r.etat === 0 && r.attributs.verdict === 'juste' && r.attributs.marge_points === 0.5
&& /C est juste/.test(r.attributs.explication) && /a 01:24/.test(r.attributs.explication)
&& !/sans EDF/.test(r.attributs.explication) && !/batterie suffit jusqu/.test(r.attributs.explication)],
['SOC au-dessus du requis PRUDENT -> « la batterie suffit »', (() => {
const m = base(); for (const [k, v] of Object.entries({
'input_boolean.evcs_balance_grid_needed': 'off', 'input_number.evcs_balance_required_soc': '40',
'input_number.evcs_balance_required_soc_prudent': '55', 'sensor.victron_battery_soc': '70',
'input_boolean.victron_evcs_night_grid_session_committed': 'off',
})) m.set(k, { state: v }); return previsionNuit(m, t); })(),
(r) => r.attributs.verdict === 'batterie_suffit' && /batterie suffit jusqu au retour du solaire/.test(r.attributs.explication)],
['prudent illisible -> jamais « la batterie suffit », mais « c est juste »', (() => {
const m = base(); for (const [k, v] of Object.entries({
'input_boolean.evcs_balance_grid_needed': 'off', 'input_number.evcs_balance_required_soc': '40',
'sensor.victron_battery_soc': '70', 'input_boolean.victron_evcs_night_grid_session_committed': 'off',
})) m.set(k, { state: v }); return previsionNuit(m, t); })(),
(r) => r.attributs.verdict === 'juste'],
['jamais d attribut qui ressemble a un SOC du matin (incident 21:16)', previsionNuit(base(), t),
(r) => !('soc_requis_au_retour_solaire_pct' in r.attributs) && 'soc_requis_maintenant_pct' in r.attributs],
];
for (const [nom, r, test] of pn) {
const ok = test(r); if (!ok) { ko++; console.log(JSON.stringify(r)); }
console.log(`${ok ? 'OK ' : 'KO '} ${nom}`);
}
globalThis.__nConso = (globalThis.__nConso || 0) + pn.length;
}
// --- Prevision VRM -> courbes du bilan (heure de Paris) ---------------------
{
// 28/09 : 12:00 et 13:00 heure de Paris = 10:00 et 11:00 UTC ; 00:00 Paris = 22:00 UTC la veille.
const paires = [[Date.parse('2026-09-27T22:00:00Z'), 0], [Date.parse('2026-09-28T10:00:00Z'), 3580],
[Date.parse('2026-09-28T11:00:00Z'), 3740], [Date.parse('2026-09-29T10:00:00Z'), 5000]];
const c = vrmVersCourbes(paires, new Date('2026-09-27T23:30:00Z'));
const vc = [
['VRM : jour de Paris (28/09) et non UTC, 24 valeurs', c,
(x) => x.day0 === '2026-09-28' && x.today.horaire.length === 24],
['VRM : 10:00 UTC range a 12 h Paris, Wh -> kWh', c,
(x) => x.today.horaire[12] === 3.58 && x.today.horaire[13] === 3.74 && x.today.totalKwh === 7.3],
['VRM : demain separe, format CSV du bilan', c,
(x) => x.tomorrow.horaire[12] === 5 && x.today.csv.split(',').length === 24 && /^\d{2}\/\d{2} \d{2}:\d{2}$/.test(x.updated)],
['VRM : reponse vide -> ok false (le bilan garde sa source)', vrmVersCourbes([], new Date()), (x) => x.ok === false],
];
for (const [nom, x, test] of vc) {
const ok = test(x); if (!ok) { ko++; console.log(JSON.stringify(x).slice(0, 300)); }
console.log(`${ok ? 'OK ' : 'KO '} ${nom}`);
}
globalThis.__nConso = (globalThis.__nConso || 0) + vc.length;
}
const n = (globalThis.__nConso || 0) + 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);