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

@@ -2,7 +2,7 @@
// Les cas PV reprennent les gammes du banc OpenClaw (HANDOFF §77) : G3, G4, G5, G7. // Les cas PV reprennent les gammes du banc OpenClaw (HANDOFF §77) : G3, G4, G5, G7.
// node tests/diagnostics-agent/run.mjs // node tests/diagnostics-agent/run.mjs
import fs from 'node:fs'; 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'; const LIM = 'voltage_current_limited', ACT = 'mppt_active';
function st({ pv, conso, reseau, soc = 80, courant = 0, edf = 'off', modes }) { 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; 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; 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}`); console.log(`\n${n - ko}/${n}`);
process.exit(ko ? 1 : 0); process.exit(ko ? 1 : 0);

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 { fileURLToPath } from 'node:url';
import pg from 'pg'; import pg from 'pg';
import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs'; 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 ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ONCE = process.argv.includes('--once'); 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', friendly_name: 'EVCS — batterie attendue au coucher du soleil', icon: 'mdi:battery-clock',
...pj.attributs, calcule_a: new Date().toISOString(), ...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). // Raccordement reseau : contacteur ouvert = import nul, pas une panne (26/09).
const rr = etatReseau(st); const rr = etatReseau(st);
await publierCapteur('sensor.evcs_reseau', rr.etat, { await publierCapteur('sensor.evcs_reseau', rr.etat, {