Nuit : la voiture profite d'EDF ferme pour la batterie, la nuit prepare le soir ; conseiller IA relecteur de la journee (revue calculee + pistes)

This commit is contained in:
Tallagrand Thierry
2026-10-08 05:31:44 +02:00
parent 043fb85383
commit 78f434778b
21 changed files with 742 additions and 552 deletions

218
tools/revue-jour.mjs Normal file
View File

@@ -0,0 +1,218 @@
// =====================================================================
// 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 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;
}
// 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;
}
/**
* @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 k0 = aInstant(d.edfKwh, p.debut), k1 = aInstant(d.edfKwh, p.fin);
const kwh = k0 !== null && k1 !== null && k1 >= k0 ? k1 - k0 : null;
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 },
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) },
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);
constats.push({
code: 'charge_max_midi',
gravite: '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 },
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 },
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')
: 'Rien a signaler : pas d\'EDF en heures pleines, pas de voiture bloquee pendant une session, batterie a son objectif en fin d\'heures creuses.';
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).
const fin = finMs;
const debut = fin - 24 * 60 * 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) }));
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'),
soc: await serie('sensor.victron_battery_soc'),
requis: await serie('input_number.evcs_balance_required_soc'),
};
}