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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z
This commit is contained in:
Tallagrand Thierry
2026-09-28 01:27:04 +02:00
parent dfd99ca957
commit 0686ef7e7d
3 changed files with 104 additions and 2 deletions

View File

@@ -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 } };
}

View File

@@ -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, {