From 0686ef7e7d295f014d55d21689c6e589dbd8c5b1 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Mon, 28 Sep 2026 01:27:04 +0200 Subject: [PATCH] sensor.evcs_prevision_nuit : la nuit a venir, uniquement depuis le bilan et des etats lus Incident 27-28/09 : l'agent presentait le surplus solaire de demain comme de l'energie batterie et lisait une projection de jour du controleur la nuit. Ce capteur ne relaie que le bilan (meme arbitre que la decision de nuit) ; bilan > 30 min -> indisponible. Tests 65/65. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z --- tests/diagnostics-agent/run.mjs | 32 +++++++++++++++- tools/diagnostics-agent.mjs | 65 +++++++++++++++++++++++++++++++++ tools/historise-evcs.mjs | 9 ++++- 3 files changed, 104 insertions(+), 2 deletions(-) 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, {