diff --git a/tests/diagnostics-agent/run.mjs b/tests/diagnostics-agent/run.mjs index e151109..b874954 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, etatReseau, importJour, COMPTEURS_IMPORT, COMPTEUR_EDF, MPPT_MODES, ORIGINE_PAR_SOURCE, consoJour, COMPTEURS_CONSO, projectionSoir } from '../../tools/diagnostics-agent.mjs'; +import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, COMPTEURS_IMPORT, COMPTEUR_EDF, MPPT_MODES, ORIGINE_PAR_SOURCE, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit } 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 }) { @@ -287,6 +287,36 @@ for (const [nom, r, test] of imp) { 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'], + ['reseau non necessaire -> 0 et « la batterie suffit »', (() => { const m = base(); m.set('input_boolean.evcs_balance_grid_needed', { state: 'off' }); return previsionNuit(m, t); })(), + (r) => r.etat === 0 && /batterie suffit/.test(r.attributs.explication)], + ]; + 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; +} + 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); diff --git a/tools/diagnostics-agent.mjs b/tools/diagnostics-agent.mjs index bfc4926..89ea7c7 100644 --- a/tools/diagnostics-agent.mjs +++ b/tools/diagnostics-agent.mjs @@ -549,3 +549,68 @@ export function projectionSoir(st) { }, }; } + +// --------------------------------------------------------------------- +// Prevision de la nuit — symetrique de evcs_bilan_nuit (28/09/2026) +// --------------------------------------------------------------------- +// Nuit du 27 au 28/09 : a « prevision de consommation de cette nuit et combien +// sur EDF ? », l'agent a assemble des helpers qui ne veulent pas dire ce qu'il +// croyait : le surplus SOLAIRE de demain presente comme energie batterie pour la +// voiture, une projection de jour du controleur lue la nuit, et un contacteur +// annonce ferme alors qu'il etait ouvert. Ce capteur ne relaie QUE le bilan +// energetique (meme arbitre que la decision de nuit) et des etats lus. +// Jamais projected_end_soc_after_charge ni budget_remaining (internes, de jour). +export const BILAN_FRAIS_MIN = 30; // six cycles du bilan, comme la garde du soir (§11.8.E) + +function ageBilanMin(texte, maintenant) { + const m = /^(\d{2})\/(\d{2}) (\d{2}):(\d{2})$/.exec(String(texte || '').trim()); + if (!m) return null; + const an = Number(new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Paris', year: 'numeric' }).format(maintenant)); + // Heure de Paris -> instant : on passe par le decalage du jour. + const iso = `${an}-${m[2]}-${m[1]}T${m[3]}:${m[4]}:00`; + const decalage = new Date(maintenant.toLocaleString('en-US', { timeZone: 'Europe/Paris' })) - + new Date(maintenant.toLocaleString('en-US', { timeZone: 'UTC' })); + const t = Date.parse(iso + 'Z') - decalage; + return Math.round((maintenant - t) / 60000); +} + +export function previsionNuit(st, maintenant = new Date()) { + const txt = (id) => { const s = st.get(id)?.state; return s === undefined || s === 'unavailable' || s === 'unknown' ? null : String(s); }; + const statut = txt('input_text.evcs_balance_status'); + const age = ageBilanMin(txt('input_text.evcs_balance_last_run'), maintenant); + const aCouvrir = nombre(st, 'input_number.evcs_balance_energy_to_cover_kwh'); + const reseauKwh = nombre(st, 'input_number.evcs_balance_grid_energy_needed_kwh'); + const reseauNecessaire = txt('input_boolean.evcs_balance_grid_needed') === 'on'; + const retour = txt('input_text.evcs_balance_crossing_hour'); + const socRequis = nombre(st, 'input_number.evcs_balance_required_soc'); + const soc = nombre(st, 'sensor.victron_battery_soc'); + const reseau = txt('sensor.evcs_reseau'); + const session = txt('input_boolean.victron_evcs_night_grid_session_committed') === 'on'; + const branche = txt('binary_sensor.victron_evcs_vehicle_connected'); + + const attributs = { + maison_kwh_a_couvrir: aCouvrir, heure_retour_solaire: retour, + reseau_necessaire: reseauNecessaire, reseau_necessaire_kwh: reseauNecessaire ? reseauKwh : 0, + soc_actuel_pct: soc, soc_requis_au_retour_solaire_pct: socRequis, + contacteur_edf: reseau, session_reseau_nuit_engagee: session, + voiture: branche === 'on' ? 'branchee' : branche === 'off' ? 'aucune' : 'inconnue', + bilan_calcule_il_y_a_min: age, + reserve_prevision: 'la production du matin vient de Forecast.Solar, qui sous-estime ces jours-ci : l heure de retour du solaire peut etre plus precoce', + }; + if (statut !== 'ok' || age === null || age > BILAN_FRAIS_MIN || aCouvrir === null || retour === null) { + return { etat: 'indisponible', attributs: { ...attributs, statut: 'indisponible', + explication: 'Le bilan energetique n est pas a jour : prevision de la nuit indisponible.' } }; + } + const voiture = attributs.voiture === 'aucune' + ? 'Aucun vehicule branche.' + : attributs.voiture === 'branchee' + ? 'Un vehicule est branche : sa charge s ajoute et sera decidee par le controleur.' + : 'Etat du vehicule inconnu.'; + const edf = reseauNecessaire + ? `Environ ${reseauKwh} kWh a prendre sur EDF avant le retour du solaire.` + : 'La batterie suffit, sans EDF, jusqu au retour du solaire.'; + const texte = `La maison doit couvrir environ ${aCouvrir} kWh jusqu au retour du solaire vers ${retour}. ` + + `${edf} Batterie a ${soc !== null ? Math.round(soc) : '?'} %. ${voiture}` + + `${session ? ' La session reseau de la nuit est engagee.' : ''}`; + return { etat: reseauNecessaire ? reseauKwh : 0, attributs: { ...attributs, statut: 'frais', explication: texte, texte_rapport: texte } }; +} diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 33bf0cb..a69b6ad 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, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir } from './diagnostics-agent.mjs'; +import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit } from './diagnostics-agent.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ONCE = process.argv.includes('--once'); @@ -1869,6 +1869,13 @@ async function publierDiagnosticPv(st) { friendly_name: 'EVCS — batterie attendue au coucher du soleil', icon: 'mdi:battery-clock', ...pj.attributs, calcule_a: new Date().toISOString(), }); + // Prevision de la nuit (28/09) : uniquement le bilan + etats lus. + const pnuit = previsionNuit(st); + await publierCapteur('sensor.evcs_prevision_nuit', pnuit.etat, { + friendly_name: 'EVCS — prevision de la nuit (EDF a prendre)', icon: 'mdi:weather-night-partly-cloudy', + unit_of_measurement: typeof pnuit.etat === 'number' ? 'kWh' : undefined, ...pnuit.attributs, + calcule_a: new Date().toISOString(), + }); // Raccordement reseau : contacteur ouvert = import nul, pas une panne (26/09). const rr = etatReseau(st); await publierCapteur('sensor.evcs_reseau', rr.etat, {