433 lines
24 KiB
JavaScript
433 lines
24 KiB
JavaScript
// =====================================================================
|
|
// REVUE DE LA JOURNEE (08/10/2026, decision du proprietaire)
|
|
//
|
|
// Chaque matin, ce que le systeme a DECIDE est confronte a ce qui s'est PASSE,
|
|
// sur les 24 h qui finissent avec la fenetre HC de nuit. Nuit du 07-08/10 : la
|
|
// HC de midi avait ete perdue, EDF s'etait ferme en heures pleines a 18:03, et
|
|
// la VW etait restee bloquee de 01:24 a 05:14 alors qu'EDF etait ferme pour la
|
|
// batterie — rien de cela n'avait ete signale par aucun automate.
|
|
//
|
|
// ⚠️ DOCTRINE (§79) : les CONSTATS sont calcules ici, par du code teste. Le
|
|
// conseiller IA ne fait que les RELIRE (pistes a examiner) ; il ne produit
|
|
// aucun chiffre et ne decide rien. Un constat ne depend jamais de lui.
|
|
//
|
|
// `constatsRevue` est pure (tests : tests/revue-jour/run.mjs) ;
|
|
// `donneesRevue` fait les lectures en base.
|
|
// =====================================================================
|
|
|
|
const MIN = 60000;
|
|
const KWH_PAR_POINT = 0.30; // courbe batterie mesuree le 20/09 (HANDOFF §74)
|
|
const SEUIL_HP_MIN = 5; // fermeture EDF en heures pleines retenue au-dela
|
|
const SEUIL_RESEAU_W = 100; // bruit de mesure du reseau (comme sensor.evcs_pv_diagnostic)
|
|
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 SEUIL_EDF_ABSENT_MIN = 10; // EDF demande mais absent (13/10)
|
|
const SEUIL_DECHARGE_W = 150; // la batterie DONNE (bruit ~ +-100 W)
|
|
const SEUIL_VOITURE_BATT_KWH = 0.5; // voiture payee par la batterie (12/10)
|
|
|
|
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) {
|
|
const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false })
|
|
.formatToParts(new Date(ms));
|
|
return Number(p.find((x) => x.type === 'hour').value) % 24 * 60 + Number(p.find((x) => x.type === 'minute').value);
|
|
}
|
|
export function hhmm(ms) {
|
|
return new Date(ms).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
const hm = (s) => { const [h, m] = String(s || '').split(':').map(Number); return Number.isFinite(h) && Number.isFinite(m) ? h * 60 + m : null; };
|
|
const f1 = (x) => String(Math.round(x * 10) / 10).replace('.', ',');
|
|
const duree = (min) => (min >= 60 ? `${Math.floor(min / 60)} h ${String(Math.round(min % 60)).padStart(2, '0')}` : `${Math.round(min)} min`);
|
|
|
|
// fenetres : [{ nom: 'nuit'|'midi', debut: 'HH:MM', fin: 'HH:MM' }]
|
|
export function enHc(ms, fenetres) {
|
|
const m = minutesParis(ms);
|
|
return fenetres.some((f) => {
|
|
const a = hm(f.debut), b = hm(f.fin);
|
|
if (a === null || b === null) return false;
|
|
return a <= b ? m >= a && m < b : m >= a || m < b;
|
|
});
|
|
}
|
|
|
|
// Valeur d'une serie en escalier (derniere valeur connue a l'instant t).
|
|
function aInstant(serie, t) {
|
|
let v = null;
|
|
for (const p of serie || []) { if (p.ts <= t) v = p.v; else break; }
|
|
return v;
|
|
}
|
|
|
|
// Energie (kWh) d'une serie de puissance (W) entre a et b, minute par minute sur
|
|
// la valeur EN VIGUEUR (les puissances ne sont historisees qu'a leur changement :
|
|
// une valeur constante vaut jusqu'au point suivant). Seules les valeurs > 0
|
|
// comptent. 13/10 : le compteur du disjoncteur EDF a ete REMPLACE (reparti de
|
|
// zero) et la revue glissante a annonce « 0 kWh achetes » pour 17 min a ~600 W.
|
|
// La puissance mesuree par le Victron ne depend d'aucun compteur remplacable.
|
|
export function energieKwh(serie, a, b, signe = 1) {
|
|
if (!serie || !serie.length) return null;
|
|
let kwh = 0;
|
|
for (let t = a; t < b; t += MIN) {
|
|
const w = signe * Number(aInstant(serie, t));
|
|
if (Number.isFinite(w) && w > 0) kwh += w / 60000;
|
|
}
|
|
return kwh;
|
|
}
|
|
|
|
// Plages contigues ou `pred(t)` est vrai, echantillonnees a la minute.
|
|
function plages(debut, fin, pred) {
|
|
const out = [];
|
|
let cur = null;
|
|
for (let t = debut; t < fin; t += MIN) {
|
|
if (pred(t)) { if (!cur) cur = { debut: t, fin: t + MIN }; else cur.fin = t + MIN; }
|
|
else if (cur) { out.push(cur); cur = null; }
|
|
}
|
|
if (cur) out.push(cur);
|
|
return out;
|
|
}
|
|
|
|
// RELEVE DE L'ETAT a un instant (08/10, proprietaire) : le conseiller ne voyait
|
|
// que la raison du controleur au debut de l'episode, et ses pistes du 08/10
|
|
// etaient fausses faute de voir l'ACM a 30 A, la consigne tenue ou le mode
|
|
// « sans EDF ». Chaque constat porte donc l'etat des leviers a ce moment-la.
|
|
function etatA(d, t) {
|
|
const v = (serie, unite, dec = 0) => {
|
|
const x = serie ? aInstant(serie, t) : null;
|
|
return x === null || !Number.isFinite(Number(x)) ? 'inconnu' : `${Math.round(Number(x) * 10 ** dec) / 10 ** dec}${unite}`;
|
|
};
|
|
let x = null; for (const r of d.decisions || []) { if (r.ts <= t) x = r; else break; }
|
|
const mode = d.modeNuit ? aInstant(d.modeNuit, t) : null;
|
|
return `a ${hhmm(t)} : ` + [
|
|
`SOC ${v(d.soc, ' %')}`,
|
|
`maison ${v(d.maisonW, ' W')}`,
|
|
`reseau ${v(d.reseauW, ' W')}`,
|
|
`consigne reseau ${v(d.consigneW, ' W')}`,
|
|
`courant de charge max fixe par l'ACM ${v(d.acmA, ' A')}`,
|
|
`borne ${v(d.borneW, ' W')}`,
|
|
// La table `decision` n'enregistre que les changements d'ETAT du controleur :
|
|
// la session de nuit se lit dans le journal quand il est fourni (13/10).
|
|
`session HC engagee ${d.sessionNuit && d.sessionNuit.length ? (aInstant(d.sessionNuit, t) === 'on' ? 'oui' : 'non') : x ? (x.session_engagee ? 'oui' : 'non') : 'inconnu'}`,
|
|
`mode de nuit ${mode || 'inconnu'}`,
|
|
`budget voiture sans EDF ${v(d.budgetKwh, ' kWh', 2)}`,
|
|
`SOC requis (bilan) ${v(d.requis, ' %', 1)}`,
|
|
`cible du matin ${x && Number.isFinite(Number(x.cible_matin_pct)) ? `${x.cible_matin_pct} %` : 'inconnu'}`,
|
|
`controleur ${x ? `${x.etat} / ${x.source_mode}` : 'inconnu'}`,
|
|
].join(', ');
|
|
}
|
|
|
|
/**
|
|
* @param {object} d
|
|
* debut, fin : ms
|
|
* fenetres : fenetres HC (voir enHc)
|
|
* contacteur : [{ ts, v: 'on'|'off', auteur }] trie, avec l'etat avant `debut` en tete
|
|
* edfKwh : [{ ts, v }] compteur EDF cumule (kWh)
|
|
* reseauW : [{ ts, v }] sensor.victron_grid_power_l1 (W)
|
|
* decisions : [{ ts, profil, source_mode, etat, session_engagee, soc_pct, cible_matin_pct, raison }]
|
|
* soc : [{ ts, v }]
|
|
* requis : [{ ts, v }] input_number.evcs_balance_required_soc
|
|
*/
|
|
export function constatsRevue(d) {
|
|
const constats = [];
|
|
const contact = d.contacteur.map((c) => ({ ts: c.ts, v: c.v }));
|
|
|
|
// 1. EDF ferme en HEURES PLEINES. Juge sur la PUISSANCE RESEAU mesuree, pas
|
|
// sur le journal du contacteur : le 07/10 l'ouverture de 07:24 n'y figurait
|
|
// pas, et le journal seul annoncait 5 h d'EDF pour 0,2 kWh reellement achete.
|
|
// Les plages separees de moins de 5 min sont fusionnees (un ecart bref de la
|
|
// mesure ne coupe pas une fermeture).
|
|
const brutes = plages(d.debut, d.fin, (t) => (aInstant(d.reseauW, t) ?? 0) > SEUIL_RESEAU_W && !enHc(t, d.fenetres));
|
|
const fusion = [];
|
|
for (const p of brutes) {
|
|
const der = fusion[fusion.length - 1];
|
|
if (der && p.debut - der.fin <= SEUIL_HP_MIN * MIN) der.fin = p.fin; else fusion.push({ ...p });
|
|
}
|
|
for (const p of fusion) {
|
|
const min = (p.fin - p.debut) / MIN;
|
|
if (min < SEUIL_HP_MIN) continue;
|
|
const kwh = energieKwh(d.reseauW, p.debut, p.fin);
|
|
const ouverture = [...d.contacteur].reverse().find((c) => c.v === 'on' && c.ts <= p.debut + 2 * MIN && c.ts >= p.debut - 30 * MIN);
|
|
const socDebut = aInstant(d.soc, p.debut);
|
|
constats.push({
|
|
code: 'edf_heures_pleines',
|
|
gravite: 'a_examiner',
|
|
debut: p.debut, fin: p.fin,
|
|
texte: `EDF ferme en heures pleines de ${hhmm(p.debut)} a ${hhmm(p.fin)} (${duree(min)})`
|
|
+ (kwh !== null ? `, ${f1(kwh)} kWh achetes` : '')
|
|
+ (socDebut !== null ? `, batterie a ${Math.round(socDebut)} % a la fermeture` : '')
|
|
+ (ouverture?.auteur ? ` ; ferme par ${ouverture.auteur}` : '') + '.',
|
|
chiffres: { minutes: Math.round(min), kwh: kwh === null ? null : Math.round(kwh * 100) / 100, soc: socDebut },
|
|
etat: etatA(d, p.debut),
|
|
contexte: (aInstant(d.decisions.map((x) => ({ ts: x.ts, v: x.raison })), p.debut) || '').slice(0, 400),
|
|
});
|
|
}
|
|
|
|
// 2. VOITURE BRANCHEE BLOQUEE pendant une session reseau HC (EDF ferme)
|
|
const dec = (t) => { let r = null; for (const x of d.decisions) { if (x.ts <= t) r = x; else break; } return r; };
|
|
for (const p of plages(d.debut, d.fin, (t) => {
|
|
const x = dec(t);
|
|
return !!x && enHc(t, d.fenetres) && aInstant(contact, t) === 'on' && x.session_engagee === true
|
|
&& /bloqu/i.test(x.source_mode || '') && !ETATS_VOITURE_ABSENTE_OU_PLEINE.test(x.etat || '');
|
|
})) {
|
|
const min = (p.fin - p.debut) / MIN;
|
|
if (min < SEUIL_VOITURE_MIN) continue;
|
|
const x = dec(p.debut);
|
|
constats.push({
|
|
code: 'voiture_bloquee_session_hc',
|
|
gravite: 'a_examiner',
|
|
debut: p.debut, fin: p.fin,
|
|
texte: `Voiture branchee mais bloquee de ${hhmm(p.debut)} a ${hhmm(p.fin)} (${duree(min)}) alors qu'EDF etait ferme en heures creuses (etat ${x.etat}).`,
|
|
chiffres: { minutes: Math.round(min) },
|
|
etat: etatA(d, p.debut + MIN),
|
|
contexte: (x.raison || '').slice(0, 400),
|
|
});
|
|
}
|
|
|
|
// 3. BATTERIE SOUS SON OBJECTIF a la fin d'une fenetre HC ou une session etait engagee
|
|
for (const f of d.fenetres) {
|
|
const b = hm(f.fin);
|
|
if (b === null) continue;
|
|
for (let t = d.debut; t <= d.fin; t += MIN) {
|
|
if (minutesParis(t) !== b) continue;
|
|
const x = dec(t - MIN);
|
|
if (!x || x.session_engagee !== true) continue;
|
|
const objectif = f.nom === 'nuit' ? Number(x.cible_matin_pct) : aInstant(d.requis, t - 5 * MIN);
|
|
const soc = aInstant(d.soc, t);
|
|
if (!Number.isFinite(objectif) || !Number.isFinite(soc)) continue;
|
|
// Jour faible a midi (08/10) : le bilan vise 100 % = « charge au maximum
|
|
// possible ». Le manque a 100 % ne dit rien ; on rend compte du gain.
|
|
if (f.nom === 'midi' && objectif >= 99.5) {
|
|
const a = hm(f.debut);
|
|
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: 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`)
|
|
+ (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),
|
|
});
|
|
continue;
|
|
}
|
|
const manque = objectif - soc;
|
|
if (manque < SEUIL_OBJECTIF_PTS) continue;
|
|
constats.push({
|
|
code: `batterie_sous_objectif_${f.nom}`,
|
|
gravite: 'a_examiner',
|
|
debut: t, fin: t,
|
|
texte: `Fin des heures creuses de ${f.nom} (${hhmm(t)}) : batterie a ${Math.round(soc)} % pour ${f1(objectif)} % vises, il manquait ${f1(manque * KWH_PAR_POINT)} kWh.`,
|
|
chiffres: { soc, objectif, manqueKwh: Math.round(manque * KWH_PAR_POINT * 10) / 10 },
|
|
// Milieu de la fenetre : la ou la recharge aurait du se faire.
|
|
etat: etatA(d, t - (f.nom === 'nuit' ? 3 * 60 : 60) * MIN),
|
|
contexte: (x.raison || '').slice(0, 400),
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. EDF DEMANDE MAIS ABSENT (13/10). Nuit du 12-13/10 : session de nuit
|
|
// engagee a 01:24, le disjoncteur n'executait pas la fermeture ; reseau a 0 W,
|
|
// batterie 31 -> 26 % jusqu'au rearmement au levier a 05:57. La revue avait
|
|
// ecrit « Rien a signaler ». Demande = session HC engagee (en HC) ou verrou
|
|
// d'hysteresis ; absent = reseau <= 100 W ET batterie qui DONNE.
|
|
const surEtat = (serie, t) => aInstant(serie || [], t) === 'on';
|
|
const absentes = plages(d.debut, d.fin, (t) =>
|
|
((surEtat(d.sessionNuit, t) && enHc(t, d.fenetres)) || surEtat(d.verrouHysteresis, t))
|
|
&& !((aInstant(d.reseauW, t) ?? 0) > SEUIL_RESEAU_W)
|
|
&& (aInstant(d.batterieW, t) ?? 0) < -SEUIL_DECHARGE_W);
|
|
const fusionAbs = [];
|
|
for (const p of absentes) {
|
|
const der = fusionAbs[fusionAbs.length - 1];
|
|
if (der && p.debut - der.fin <= 5 * MIN) der.fin = p.fin; else fusionAbs.push({ ...p });
|
|
}
|
|
for (const p of fusionAbs) {
|
|
const min = (p.fin - p.debut) / MIN;
|
|
if (min < SEUIL_EDF_ABSENT_MIN) continue;
|
|
const s0 = aInstant(d.soc, p.debut), s1 = aInstant(d.soc, p.fin);
|
|
const donne = energieKwh(d.batterieW, p.debut, p.fin, -1);
|
|
const pourquoi = surEtat(d.sessionNuit, p.debut) ? 'session d\'heures creuses engagee' : 'verrou d\'hysteresis (batterie basse)';
|
|
constats.push({
|
|
code: 'edf_absent_session', gravite: 'a_examiner', debut: p.debut, fin: p.fin,
|
|
texte: `EDF demande (${pourquoi}) mais ABSENT de ${hhmm(p.debut)} a ${hhmm(p.fin)} (${duree(min)}) : reseau a 0 W`
|
|
+ (s0 !== null && s1 !== null ? `, batterie ${Math.round(s0)} % -> ${Math.round(s1)} %` : '')
|
|
+ (donne !== null ? ` (${f1(donne)} kWh donnes par la batterie au lieu d'EDF)` : '')
|
|
+ ' ; l\'ordre de fermeture n\'a pas ete execute par le disjoncteur.',
|
|
chiffres: { minutes: Math.round(min), socDebut: s0, socFin: s1, kwh: donne === null ? null : Math.round(donne * 100) / 100 },
|
|
etat: etatA(d, p.debut + MIN), contexte: '',
|
|
});
|
|
}
|
|
|
|
// 6. VOITURE PAYEE PAR LA BATTERIE (12/10). VW de 16:16 a 16:44 tenue par le
|
|
// temps mini puis la tolerance nuage, batterie a -40/-74 A : ~1,9 kWh de la
|
|
// batterie pour la voiture. Hors session reseau ; part batterie = min(borne,
|
|
// decharge de la batterie), minute par minute.
|
|
const payees = plages(d.debut, d.fin, (t) => {
|
|
const x = dec(t);
|
|
return (aInstant(d.borneW, t) ?? 0) > 500 && (aInstant(d.batterieW, t) ?? 0) < -SEUIL_DECHARGE_W
|
|
&& !(x && /r[ée]seau/i.test(x.source_mode || '')) && !((aInstant(d.reseauW, t) ?? 0) > SEUIL_RESEAU_W);
|
|
});
|
|
const fusionPay = [];
|
|
for (const p of payees) {
|
|
const der = fusionPay[fusionPay.length - 1];
|
|
if (der && p.debut - der.fin <= 5 * MIN) der.fin = p.fin; else fusionPay.push({ ...p });
|
|
}
|
|
for (const p of fusionPay) {
|
|
let kwh = 0;
|
|
for (let t = p.debut; t < p.fin; t += MIN) kwh += Math.min(aInstant(d.borneW, t) ?? 0, -(aInstant(d.batterieW, t) ?? 0)) / 60000;
|
|
if (kwh < SEUIL_VOITURE_BATT_KWH) continue;
|
|
const min = (p.fin - p.debut) / MIN;
|
|
const x = dec(p.debut);
|
|
constats.push({
|
|
code: 'voiture_payee_par_batterie', gravite: 'a_examiner', debut: p.debut, fin: p.fin,
|
|
texte: `Voiture en charge payee par la batterie maison de ${hhmm(p.debut)} a ${hhmm(p.fin)} (${duree(min)}) : ${f1(kwh)} kWh pris a la batterie (etat ${x ? x.etat : 'inconnu'}).`,
|
|
chiffres: { minutes: Math.round(min), kwh: Math.round(kwh * 100) / 100 },
|
|
etat: etatA(d, p.debut + 2 * MIN), contexte: (x?.raison || '').slice(0, 400),
|
|
});
|
|
}
|
|
|
|
// 7. INTERVENTIONS MANUELLES (info, 13/10) : une journee « tenue » grace a un
|
|
// geste du proprietaire n'est pas une journee sans probleme.
|
|
for (const iv of d.interventions || []) {
|
|
if (iv.ts < d.debut || iv.ts >= d.fin) continue;
|
|
constats.push({ code: 'intervention', gravite: 'info', debut: iv.ts, fin: iv.ts,
|
|
texte: `Intervention a ${hhmm(iv.ts)} : ${iv.texte}.`, chiffres: {}, etat: etatA(d, iv.ts), contexte: '' });
|
|
}
|
|
|
|
constats.sort((a, b) => a.debut - b.debut);
|
|
const texte = constats.length
|
|
? constats.map((c) => `- ${c.texte}`).join('\n')
|
|
: 'Aucun des cas surveilles n\'est apparu : EDF en heures pleines, EDF demande mais absent, voiture bloquee pendant une session, '
|
|
+ 'voiture payee par la batterie, batterie sous son objectif en fin d\'heures creuses, recharge maximale bridee, intervention manuelle. '
|
|
+ 'Ce n\'est pas la preuve que tout s\'est bien passe : seuls ces cas sont verifies.';
|
|
return { constats, texte };
|
|
}
|
|
|
|
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 - 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();
|
|
|
|
const avant = await q(`SELECT ts, detail->>'apres' v, detail->>'auteur_nom' auteur_nom, detail->>'auteur' auteur FROM evenement
|
|
WHERE genre='reseau' AND resume LIKE 'Contacteur EDF%' AND ts < $1 ORDER BY ts DESC LIMIT 1`, [d0]);
|
|
const bascules = await q(`SELECT ts, detail->>'apres' v, detail->>'auteur_nom' auteur_nom, detail->>'auteur' auteur, resume FROM evenement
|
|
WHERE genre='reseau' AND resume LIKE 'Contacteur EDF%' AND ts >= $1 AND ts < $2 ORDER BY ts`, [d0, d1]);
|
|
const auteurDe = (r) => {
|
|
const m = /— par (.+)$/.exec(r.resume || '');
|
|
return r.auteur_nom || (m ? m[1] : null) || r.auteur || null;
|
|
};
|
|
const contacteur = [
|
|
...avant.map((r) => ({ ts: debut, v: r.v, auteur: auteurDe(r) })),
|
|
...bascules.map((r) => ({ ts: ms(r), v: r.v, auteur: auteurDe(r) })),
|
|
];
|
|
const serie = async (cle) => (await q(`SELECT ts, valeur v FROM (
|
|
(SELECT ts, valeur FROM mesure WHERE cle=$1 AND ts < $2 ORDER BY ts DESC LIMIT 1)
|
|
UNION ALL (SELECT ts, valeur FROM mesure WHERE cle=$1 AND ts >= $2 AND ts < $3)) s ORDER BY ts`, [cle, d0, d1]))
|
|
.map((r) => ({ ts: ms(r), v: Number(r.v) }));
|
|
// Etats discrets suivis par le journal (evenement), avec l'etat avant `debut`.
|
|
const etats = async (entite) => (await q(`SELECT ts, v FROM (
|
|
(SELECT ts, detail->>'apres' v FROM evenement WHERE detail->>'entite'=$1 AND ts < $2 ORDER BY ts DESC LIMIT 1)
|
|
UNION ALL (SELECT ts, detail->>'apres' v FROM evenement WHERE detail->>'entite'=$1 AND ts >= $2 AND ts < $3)) s ORDER BY ts`, [entite, d0, d1]))
|
|
.map((r, i) => ({ ts: i === 0 && ms(r) < debut ? debut : ms(r), v: r.v }));
|
|
// Gestes manuels : contacteur manoeuvre hors du systeme (aucun auteur HA :
|
|
// levier ou appli), charge batterie manuelle armee, mode de nuit change
|
|
// autrement que par l'automatisation de 12:00.
|
|
const interventions = async () => {
|
|
// Contacteur : seulement un etat qui TIENT 5 min (la nuit du 12-13/10, les
|
|
// claquements sans auteur identifie ressemblaient sinon a des gestes).
|
|
const rows = await q(`SELECT ts, resume, detail FROM (
|
|
SELECT ts, resume, detail, lead(ts) OVER (PARTITION BY detail->>'entite' ORDER BY ts) suivant
|
|
FROM evenement WHERE ts >= $1::timestamptz - interval '1 day' AND ts < $2::timestamptz + interval '1 day' AND detail->>'entite' IN
|
|
('switch.edf_general_ok_switch','input_boolean.evcs_battery_manual_hc','input_select.victron_evcs_night_charge_mode')) e
|
|
WHERE ts >= $1 AND ts < $2 AND (
|
|
(detail->>'entite'='switch.edf_general_ok_switch' AND detail->>'auteur' IS NULL AND NOT coalesce((detail->>'aller_retour')::boolean, false)
|
|
AND (suivant IS NULL OR suivant - ts >= interval '5 minutes'))
|
|
OR (detail->>'entite'='input_boolean.evcs_battery_manual_hc' AND detail->>'apres'='on')
|
|
OR (detail->>'entite'='input_select.victron_evcs_night_charge_mode' AND coalesce(detail->>'auteur_type','') <> 'automation'
|
|
-- remise automatique a « sans_edf » a 12:00 (evcs_charge_nuit_retour_sans_edf), pas toujours attribuee
|
|
AND NOT (detail->>'apres' = 'sans_edf' AND to_char(ts AT TIME ZONE 'Europe/Paris', 'HH24:MI') BETWEEN '11:59' AND '12:02'))) ORDER BY ts`, [d0, d1]);
|
|
return rows.map((r) => {
|
|
const e = r.detail?.entite, ap = r.detail?.apres;
|
|
const texte = e === 'switch.edf_general_ok_switch'
|
|
? `contacteur EDF ${ap === 'on' ? 'ferme' : 'ouvert'} hors du systeme (levier ou appli)`
|
|
: e === 'input_boolean.evcs_battery_manual_hc' ? 'charge batterie manuelle en heures creuses armee'
|
|
: `mode de nuit change en « ${ap} »${r.detail?.auteur_nom ? ` (${r.detail.auteur_nom})` : ''}`;
|
|
return { ts: ms(r), texte };
|
|
});
|
|
};
|
|
const decisions = (await q(`SELECT * FROM (
|
|
(SELECT ts, profil, source_mode, etat, session_engagee, soc_pct, cible_matin_pct, raison FROM decision WHERE ts < $1 ORDER BY ts DESC LIMIT 1)
|
|
UNION ALL (SELECT ts, profil, source_mode, etat, session_engagee, soc_pct, cible_matin_pct, raison FROM decision WHERE ts >= $1 AND ts < $2)) s ORDER BY ts`, [d0, d1]))
|
|
.map((r) => ({ ...r, ts: ms(r) }));
|
|
return {
|
|
debut, fin, fenetres, contacteur, decisions,
|
|
edfKwh: await serie('sensor.edf_general_ok_energie_totale'),
|
|
reseauW: await serie('sensor.victron_grid_power_l1'),
|
|
maisonW: await serie('sensor.victron_consumption_power_l1'),
|
|
borneW: await serie('sensor.victron_evcs_ac_power'),
|
|
consigneW: await serie('number.gx_device_ac_power_setpoint'),
|
|
acmA: await serie('sensor.victron_max_charge_current_setting'),
|
|
budgetKwh: await serie('input_number.evcs_balance_night_vehicle_budget_kwh'),
|
|
modeNuit: (await q(`SELECT ts, v FROM (
|
|
(SELECT ts, detail->>'apres' v FROM evenement WHERE detail->>'entite'='input_select.victron_evcs_night_charge_mode' AND ts < $1 ORDER BY ts DESC LIMIT 1)
|
|
UNION ALL (SELECT ts, detail->>'apres' v FROM evenement WHERE detail->>'entite'='input_select.victron_evcs_night_charge_mode' AND ts >= $1 AND ts < $2)) s ORDER BY ts`, [d0, d1]))
|
|
.map((r) => ({ ts: ms(r), v: r.v })),
|
|
soc: await serie('sensor.victron_battery_soc'),
|
|
requis: await serie('input_number.evcs_balance_required_soc'),
|
|
batterieW: await serie('sensor.victron_battery_power'),
|
|
sessionNuit: await etats('input_boolean.victron_evcs_night_grid_session_committed'),
|
|
verrouHysteresis: await etats('input_boolean.victron_grid_hysteresis_latch'),
|
|
interventions: await interventions(),
|
|
};
|
|
}
|