// 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, nuitMaisonPrevue } 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; } // --- Nuit de la maison prevue par la soiree : les 24 nuits REELLES (04->27/09) --- { const N = [[4.11,10.29],[6.52,9.81],[4.65,9.9],[3.76,8.66],[3.53,8.55],[3.61,9.48],[3.73,8.7],[7.04,11.88], [2.96,10.36],[5.73,13.63],[5.39,8.93],[3.12,8.14],[4.35,12.44],[5.08,12.64],[3.74,10],[4.24,10.75], [4.96,12.18],[5.47,11.43],[6.68,13.85],[9.48,14.95],[7.48,9.56],[4.41,9.72],[3.67,10.04],[4.81,8.87]] .map(([soiree, nuit]) => ({ soiree, nuit })); const soir = new Date('2026-09-28T21:00:00Z'); // 23:00 a Paris const r = nuitMaisonPrevue(N, 6.27, soir); const cas = [ // Verifie par un calcul independant (Python) : a 6,979, b 0,7362, r2 0,388, // moyenne 10,615, prevue 11,595, facteur 1,0924. ['soir du 28/09 : soiree 6,27 -> nuit prevue 11,6 kWh (a 6,98, b 0,736, r2 0,39)', r, (x) => x.etat === 11.6 && x.attributs.a === 6.98 && x.attributs.b === 0.736 && x.attributs.r2 === 0.39], ['facteur = prevue / moyenne des memes nuits (10,62) -> 1,092', r, (x) => x.attributs.facteur === 1.092 && x.attributs.nuit_moyenne_kwh === 10.62], ['fenetre 21:30 -> 09:00 de la bonne nuit', r, (x) => x.attributs.debut_ms === Date.parse('2026-09-28T19:30:00Z') && x.attributs.fin_ms === Date.parse('2026-09-29T07:00:00Z')], ['03:00 : c est la nuit commencee la veille', nuitMaisonPrevue(N, 6.27, new Date('2026-09-29T01:00:00Z')), (x) => x.etat === 11.6 && x.attributs.debut_ms === Date.parse('2026-09-28T19:30:00Z')], ['14:00 -> hors_fenetre (pas de soiree complete)', nuitMaisonPrevue(N, 6.27, new Date('2026-09-28T12:00:00Z')), (x) => x.etat === 'hors_fenetre' && x.attributs.facteur === null], ['10 nuits seulement -> indisponible', nuitMaisonPrevue(N.slice(0, 10), 6.27, soir), (x) => x.etat === 'indisponible' && x.attributs.facteur === null], ['nuage sans relation (r2 < 0,2) -> indisponible', nuitMaisonPrevue(N.map((p, i) => ({ soiree: p.soiree, nuit: 10 + (i % 2) })), 6.27, soir), (x) => x.etat === 'indisponible'], ['soiree illisible -> indisponible', nuitMaisonPrevue(N, NaN, soir), (x) => x.etat === 'indisponible'], ]; for (const [nom, x, test] of cas) { const ok = test(x); if (!ok) { ko++; console.log(JSON.stringify(x)); } console.log(`${ok ? 'OK ' : 'KO '} ${nom}`); } globalThis.__nConso = (globalThis.__nConso || 0) + cas.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' && /au moins 5,77 kWh achetes/.test(r.attributs.explication) && /plus la consommation de la maison/.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'], // Releve REEL du 28/09 21:32 : verdict reseau_necessaire, manque 0,14 kWh. // L'agent a dit « environ 0,14 kWh a prendre sur EDF » ; le reseau en a // fourni 4,6 (la maison sur le reseau de 01:24 a 07:24). ['28/09 21:32 : manque 0,14 mais la maison passe la nuit sur le reseau -> « environ 4,6 kWh achetes »', (() => { const m = base(); for (const [k, v] of Object.entries({ 'input_number.evcs_balance_grid_energy_needed_kwh': '0.14', 'input_boolean.victron_evcs_night_grid_session_committed': 'off', 'input_number.evcs_balance_night_hc_house_kwh': '4.5', 'sensor.evcs_reseau': 'non_raccorde', 'input_datetime.victron_evcs_hc_night_start': '01:24:00', 'input_datetime.victron_evcs_hc_night_stop': '07:24:00', })) m.set(k, { state: v }); return previsionNuit(m, t); })(), (r) => r.etat === 4.6 && r.attributs.verdict === 'reseau_necessaire' && r.attributs.import_prevu_kwh === 4.6 && r.attributs.manque_batterie_kwh === 0.14 && /EDF sera branche de 01:24 a 07:24 : environ 4,6 kWh achetes/.test(r.attributs.explication) && /maison 4,5, batterie 0,14/.test(r.attributs.explication) && !/0,14 kWh a prendre/.test(r.attributs.explication)], ['session engagee avec la maison connue -> achat = maison + batterie, jusqu a 07:24', (() => { const m = base(); for (const [k, v] of Object.entries({ 'input_number.evcs_balance_night_hc_house_kwh': '3.2', 'input_datetime.victron_evcs_hc_night_stop': '07:24:00', })) m.set(k, { state: v }); return previsionNuit(m, t); })(), (r) => r.etat === 9 && /jusqu a 07:24 : environ 9 kWh achetes/.test(r.attributs.explication)], ['« c est juste » annonce aussi l achat si EDF s engage', (() => { 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', 'input_number.evcs_balance_night_hc_house_kwh': '4.46', })) m.set(k, { state: v }); return previsionNuit(m, t); })(), (r) => r.etat === 0 && r.attributs.verdict === 'juste' && /la maison y achetera alors environ 4,5 kWh/.test(r.attributs.explication)], // Releve REEL du 29/09 10:45 (banc SPARK) : « la maison doit couvrir 0 kWh // jusqu au retour du solaire vers 10 h … la batterie suffit » en reponse a // « combien d EDF la nuit prochaine ? ». ['29/09 10:45 : le jour, pas de fausse prevision de nuit, un repere provisoire', (() => { const m = base(); for (const [k, v] of Object.entries({ 'input_number.evcs_balance_energy_to_cover_kwh': '0', 'input_boolean.evcs_balance_grid_needed': 'off', 'input_number.evcs_balance_grid_energy_needed_kwh': '0', 'input_text.evcs_balance_crossing_hour': '10 h', 'input_number.evcs_balance_required_soc': '22', 'input_number.evcs_balance_required_soc_prudent': '22', 'sensor.victron_battery_soc': '46', 'input_boolean.victron_evcs_night_grid_session_committed': 'off', 'sensor.evcs_reseau': 'non_raccorde', 'input_number.evcs_balance_night_hc_house_kwh': '4.16', 'input_datetime.victron_evcs_hc_night_start': '01:24:00', 'input_datetime.victron_evcs_hc_night_stop': '07:24:00', 'input_text.evcs_balance_next_deficit_window': '18 h → 09 h', 'input_number.evcs_balance_next_deficit_required_soc': '81.6', 'input_number.evcs_balance_next_deficit_kwh': '17.87', })) m.set(k, { state: v }); return previsionNuit(m, t); })(), (r) => r.etat === 'jour' && r.attributs.verdict === 'jour' && /trop tot pour la prevision de la nuit/.test(r.attributs.texte_rapport) && /de 18 h a 09 h sans EDF \(environ 17,87 kWh\), il faudrait la batterie a 82 % a 18 h/.test(r.attributs.texte_rapport) && /Si EDF s engage a 01:24, la maison y achetera environ 4,2 kWh jusqu a 07:24/.test(r.attributs.texte_rapport) && !/batterie suffit/.test(r.attributs.texte_rapport) && !/couvrir environ 0 kWh/.test(r.attributs.texte_rapport)], ['le jour, fenetre de deficit inconnue -> pas de repere invente', (() => { const m = base(); for (const [k, v] of Object.entries({ 'input_number.evcs_balance_energy_to_cover_kwh': '0', 'input_boolean.evcs_balance_grid_needed': 'off', 'input_boolean.victron_evcs_night_grid_session_committed': 'off', 'input_text.evcs_balance_next_deficit_window': 'déficit en cours', })) m.set(k, { state: v }); return previsionNuit(m, t); })(), (r) => r.etat === 'jour' && !/Repere provisoire/.test(r.attributs.texte_rapport)], // Releve REEL du 29/09 18:05 : bilan `degrade` (forme minoree), SOC 83, // requis 67,2, 13,56 kWh a couvrir. Le vocal a dit « indisponible ». ['29/09 18:05 : bilan degrade -> prevision publiee SOUS RESERVE, pas muette', (() => { const m = base(); for (const [k, v] of Object.entries({ 'input_text.evcs_balance_status': 'degrade', 'input_number.evcs_balance_energy_to_cover_kwh': '13.56', 'input_boolean.evcs_balance_grid_needed': 'off', 'input_number.evcs_balance_required_soc': '67.2', 'input_number.evcs_balance_required_soc_prudent': '76', 'sensor.victron_battery_soc': '83', 'input_boolean.victron_evcs_night_grid_session_committed': 'off', '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.statut === 'sous_reserve' && r.attributs.verdict === 'batterie_suffit' && /Sous reserve/.test(r.attributs.texte_rapport) && /reprise a 01:24/.test(r.attributs.texte_rapport)], ['statut inconnu -> indisponible, avec la raison', (() => { const m = base(); m.set('input_text.evcs_balance_status', { state: 'erreur' }); return previsionNuit(m, t); })(), (r) => r.etat === 'indisponible' && /etat « erreur »/.test(r.attributs.explication)], ['bilan perime -> indisponible, dit depuis combien de temps', previsionNuit(base(), new Date('2026-09-28T00:30:00Z')), (r) => r.etat === 'indisponible' && /recalcule depuis 65 min/.test(r.attributs.explication)], ['plus jamais d attribut reseau_necessaire_kwh (lu comme un achat le 28/09)', previsionNuit(base(), t), (r) => !('reseau_necessaire_kwh' in r.attributs) && 'import_prevu_kwh' in r.attributs], ['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);