Revue glissante toutes les 15 min (alertes), recharge maximale jugee contre le possible

This commit is contained in:
Tallagrand Thierry
2026-10-10 15:07:37 +02:00
parent b9bdb44f4c
commit 3988a0a7f5
4 changed files with 146 additions and 10 deletions

View File

@@ -1280,6 +1280,55 @@ async function publierRevueHistorique(client) {
return 1;
}
// ---------------------------------------------------------------------
// REVUE GLISSANTE (10/10/2026, proprietaire) : les memes constats que la revue
// du matin, toutes les 15 min sur les 3 dernieres heures. Un constat « a
// examiner » nouveau devient une ALERTE : sensor.evcs_alerte_revue (etat = id),
// relayee par SPARK sur WhatsApp, historisee dans revue_alerte. La voiture
// bloquee reste a l'arbitre (pas de doublon). Un episode qui dure (EDF en heures
// pleines) ne donne qu'une alerte : on prolonge la ligne existante.
const CODES_GLISSANTS = new Set(['edf_heures_pleines', 'charge_max_bridee_en_cours', 'charge_max_bridee',
'batterie_sous_objectif_nuit', 'batterie_sous_objectif_midi']);
let revueGlissanteSchema = false;
async function revueGlissante(sql, client, st) {
if (!client) return 0;
if (!revueGlissanteSchema) {
await client.query(`CREATE TABLE IF NOT EXISTS revue_alerte (
id text PRIMARY KEY, code text, debut timestamptz, fin timestamptz, texte text, etat text, envoyee timestamptz DEFAULT now())`);
revueGlissanteSchema = true;
}
const hc = (id) => (st.get(id)?.state || '').slice(0, 5);
const fenetres = [
{ nom: 'nuit', debut: hc('input_datetime.victron_evcs_hc_night_start'), fin: hc('input_datetime.victron_evcs_hc_night_stop') },
{ nom: 'midi', debut: hc('input_datetime.victron_evcs_hc_midday_start'), fin: hc('input_datetime.victron_evcs_hc_midday_stop') },
];
const d = await donneesRevue(client, fenetres, Date.now(), 180);
d.enCours = true;
d.abonnementVa = Number(st.get('input_number.victron_grid_subscription_va')?.state) || 12000;
const { constats } = constatsRevue(d);
let n = 0;
for (const c of constats) {
if (c.gravite !== 'a_examiner' || !CODES_GLISSANTS.has(c.code)) continue;
if (c.code === 'edf_heures_pleines' && (c.chiffres?.minutes ?? 0) < 15) continue;
const { rows: [deja] } = await client.query(
`SELECT id FROM revue_alerte WHERE code = $1 AND fin >= $2 ORDER BY fin DESC LIMIT 1`,
[c.code, new Date(c.debut - 20 * 60000)]);
if (deja) { await client.query(`UPDATE revue_alerte SET fin = $2, texte = $3 WHERE id = $1`, [deja.id, new Date(c.fin), c.texte]); continue; }
const id = `${c.code}-${new Date(c.debut).toISOString().slice(0, 16)}`;
await client.query(`INSERT INTO revue_alerte (id, code, debut, fin, texte, etat) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`,
[id, c.code, new Date(c.debut), new Date(c.fin), c.texte, c.etat || null]);
const { rows: hist } = await client.query(`SELECT id, code, texte, to_char(envoyee AT TIME ZONE 'Europe/Paris','DD/MM HH24:MI') a
FROM revue_alerte ORDER BY envoyee DESC LIMIT 10`);
await publierCapteur('sensor.evcs_alerte_revue', id, {
friendly_name: 'Alerte de la revue glissante', icon: 'mdi:alert-outline',
id, code: c.code, texte: c.texte, etat_systeme: c.etat || '', a: new Date().toISOString(), dernieres: hist,
});
await journaliser(sql, 'revue', `Alerte revue glissante : ${c.code}`, { id, texte: c.texte });
n++;
}
return n;
}
function decalageParis(jour) {
const d = new Date(`${jour}T12:00:00Z`);
const h = Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', hour12: false }).format(d));
@@ -2669,6 +2718,9 @@ async function cycle(client) {
arbitrage: DRY ? 0 : await arbitrer(sql, client, st).catch((e) => {
console.error('arbitrage :', e.message); return 0;
}),
revueGlissante: (DRY || new Date().getMinutes() % 15 !== 11) ? 0 : await revueGlissante(sql, client, st).catch((e) => {
console.error('revue glissante :', e.message); return 0;
}),
revue: DRY ? 0 : await revueDeLaJournee(sql, client, st).catch((e) => {
console.error('revue de la journee :', e.message); return 0;
}),

View File

@@ -22,6 +22,27 @@ const SEUIL_RESEAU_W = 100; // bruit de mesure du reseau (comme sensor.
const SEUIL_VOITURE_MIN = 10; // voiture bloquee pendant une session HC
const SEUIL_OBJECTIF_PTS = 2; // batterie sous son objectif en fin de HC
const CHARGEURS_MAX_W = 7300; // 2 MultiPlus, 140 A (HANDOFF 07/10)
const RENDEMENT_CHARGE = 0.92;
const PART_ABONNEMENT = 0.9; // marge sous l'abonnement, comme l'ACM
// Recharge maximale de midi (jour faible) : gain reel contre ce que l'abonnement
// permettait, minute par minute (90 % de l'abonnement - maison, borne aux
// chargeurs, rendement), sans compter le solaire (estimation basse du possible).
export function rendementChargeMax(d, t0, t1) {
const s0 = aInstant(d.soc, t0), s1 = aInstant(d.soc, t1);
if (!Number.isFinite(s0) || !Number.isFinite(s1) || t1 <= t0) return null;
const abo = Number(d.abonnementVa) || 12000;
let possibleKwh = 0;
for (let t = t0; t < t1; t += MIN) {
const maison = d.maisonW ? aInstant(d.maisonW, t) : null;
const libre = Math.max(0, PART_ABONNEMENT * abo - (Number.isFinite(maison) ? maison : 0));
possibleKwh += Math.min(CHARGEURS_MAX_W, libre) * RENDEMENT_CHARGE / 60000;
}
const gainKwh = (s1 - s0) * KWH_PAR_POINT;
return { s0, s1, gainKwh, possibleKwh, bridee: possibleKwh > 1 && gainKwh < 0.5 * possibleKwh };
}
const ETATS_VOITURE_ABSENTE_OU_PLEINE = /not_connected|done|full|complete|charged/i;
function minutesParis(ms) {
@@ -178,13 +199,17 @@ export function constatsRevue(d) {
let t0 = null;
for (let u = t - 4 * 60 * MIN; u < t; u += MIN) if (minutesParis(u) === a) { t0 = u; break; }
const soc0 = t0 === null ? null : aInstant(d.soc, t0);
// 10/10 : le gain se juge contre le POSSIBLE. Le 09/10, +3,9 kWh en 2 h pour
// ~8-10 kWh permis : la revue ne disait que « info ».
const r = t0 === null ? null : rendementChargeMax(d, t0, t);
constats.push({
code: 'charge_max_midi',
gravite: 'info',
code: r && r.bridee ? 'charge_max_bridee' : 'charge_max_midi',
gravite: r && r.bridee ? 'a_examiner' : 'info',
debut: t, fin: t,
texte: `Heures creuses de midi en charge maximale (jour faible) : batterie ${soc0 === null ? '?' : Math.round(soc0)} % -> ${Math.round(soc)} %`
+ (soc0 === null ? '.' : ` (+${f1((soc - soc0) * KWH_PAR_POINT)} kWh).`),
chiffres: { socDebut: soc0, socFin: soc },
+ (soc0 === null ? '.' : ` (+${f1((soc - soc0) * KWH_PAR_POINT)} kWh`)
+ (r ? ` pour environ ${f1(r.possibleKwh)} kWh permis par l'abonnement${r.bridee ? ', moins de la moitie : recharge BRIDEE' : ''}).` : (soc0 === null ? '' : ').')),
chiffres: { socDebut: soc0, socFin: soc, possibleKwh: r ? Math.round(r.possibleKwh * 10) / 10 : null },
etat: etatA(d, t - 60 * MIN),
contexte: (x.raison || '').slice(0, 400),
});
@@ -205,6 +230,28 @@ export function constatsRevue(d) {
}
}
// 4. EN COURS (revue glissante, 10/10) : recharge maximale de midi bridee, jugee
// apres au moins 60 min de fenetre (le constat de fin de fenetre arrive trop tard).
if (d.enCours) {
const midi = d.fenetres.find((f) => f.nom === 'midi');
const a = midi ? hm(midi.debut) : null, b = midi ? hm(midi.fin) : null;
const m = minutesParis(d.fin);
const x = dec(d.fin);
const requis = aInstant(d.requis, d.fin);
if (a !== null && b !== null && m >= a + 60 && m < b && x && x.session_engagee === true && requis >= 99.5) {
const t0 = d.fin - (m - a) * MIN;
const r = rendementChargeMax(d, t0, d.fin);
if (r && r.bridee) {
constats.push({
code: 'charge_max_bridee_en_cours', gravite: 'a_examiner', debut: t0, fin: d.fin,
texte: `Recharge maximale de midi BRIDEE : depuis ${hhmm(t0)}, batterie ${Math.round(r.s0)} % -> ${Math.round(r.s1)} % (+${f1(r.gainKwh)} kWh) pour environ ${f1(r.possibleKwh)} kWh permis par l'abonnement.`,
chiffres: { gainKwh: Math.round(r.gainKwh * 10) / 10, possibleKwh: Math.round(r.possibleKwh * 10) / 10 },
etat: etatA(d, d.fin), contexte: (x.raison || '').slice(0, 400),
});
}
}
}
constats.sort((a, b) => a.debut - b.debut);
const texte = constats.length
? constats.map((c) => `- ${c.texte}`).join('\n')
@@ -212,10 +259,11 @@ export function constatsRevue(d) {
return { constats, texte };
}
export async function donneesRevue(client, fenetres, finMs) {
// 24 h qui se terminent a la fin de la fenetre HC de nuit (07:24 + 1 min).
export async function donneesRevue(client, fenetres, finMs, dureeMin = 24 * 60) {
// Par defaut 24 h qui se terminent a la fin de la fenetre HC de nuit (07:24 +
// 1 min) ; la revue glissante (10/10) regarde les 2 ou 3 dernieres heures.
const fin = finMs;
const debut = fin - 24 * 60 * MIN;
const debut = fin - dureeMin * MIN;
const q = async (sql, v) => (await client.query(sql, v)).rows;
const d0 = new Date(debut), d1 = new Date(fin);
const ms = (r) => new Date(r.ts).getTime();