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

View File

@@ -31,6 +31,7 @@ import { fileURLToPath } from 'node:url';
import pg from 'pg';
import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from './kilometrage.mjs';
import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs';
import { donneesRevue, constatsRevue } from './revue-jour.mjs';
import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from './diagnostics-agent.mjs';
import { transitionsDansSerie } from './allers-retours.mjs';
import { energieSur24h, energieDepuis, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.mjs';
@@ -1122,6 +1123,83 @@ async function envoyerRapportDuMatin(sql, client) {
return 1;
}
// ---------------------------------------------------------------------
// REVUE DE LA JOURNEE (08/10/2026, tools/revue-jour.mjs) : a 07:30, les
// constats (calcules, testes) sont publies dans sensor.evcs_revue_jour ; le
// conseiller IA les relit vers 07:35 et publie ses pistes dans
// sensor.evcs_advisor_revue ; a 07:45 le courriel « revue de la journee » part
// avec les constats et, s'il a repondu (sinon a 08:15 sans lui), ses pistes —
// presentees comme un AVIS, jamais comme un fait.
const REVUE_PUBLIEE = 7 * 60 + 30;
const REVUE_COURRIEL = 7 * 60 + 45;
const REVUE_COURRIEL_SANS_AVIS = 8 * 60 + 15;
function partsParis() {
const p = Object.fromEntries(new Intl.DateTimeFormat('fr-FR', {
timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(new Date()).filter((x) => x.type !== 'literal').map((x) => [x.type, Number(x.value)]));
return { min: p.hour * 60 + p.minute, jour: `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}` };
}
async function dejaFait(client, genre, jour) {
const { rowCount } = await client.query(`SELECT 1 FROM evenement WHERE genre = $1
AND ts >= $2::date AND ts < $2::date + interval '1 day' LIMIT 1`, [genre, jour]);
return rowCount > 0;
}
async function revueDeLaJournee(sql, client, st) {
if (!client) return 0;
const { min, jour } = partsParis();
if (min < REVUE_PUBLIEE || min > 10 * 60) return 0;
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') },
];
let n = 0;
if (!(await dejaFait(client, 'revue', jour))) {
// Fin de la fenetre = fin des HC de nuit d'aujourd'hui + 1 min.
const fin = Date.parse(`${jour}T${fenetres[0].fin || '07:24'}:00${decalageParis(jour)}`) + 60000;
const r = constatsRevue(await donneesRevue(client, fenetres, fin));
const aExaminer = r.constats.filter((c) => c.gravite === 'a_examiner').length;
await publierCapteur('sensor.evcs_revue_jour', jour, {
friendly_name: 'Revue de la journee',
a_examiner: aExaminer,
constats: r.constats.map((c) => ({ code: c.code, gravite: c.gravite, texte: c.texte, contexte: c.contexte })),
texte: r.texte,
});
await journaliser(sql, 'revue', `Revue de la journee : ${aExaminer} point(s) a examiner`, { jour, texte: r.texte });
n++;
}
if (min >= REVUE_COURRIEL && !(await dejaFait(client, 'revue_courriel', jour))) {
const revue = st.get('sensor.evcs_revue_jour');
const avis = st.get('sensor.evcs_advisor_revue');
const avisDuJour = avis && avis.state === jour ? avis.attributes : null;
if (revue && revue.state === jour && (avisDuJour || min >= REVUE_COURRIEL_SANS_AVIS)) {
const a = revue.attributes || {};
const pistes = (avisDuJour?.pistes || []).map((p) => `- ${p.piste}`);
const texte = [
`Revue de la journee (24 h jusqu'a la fin des heures creuses de ce matin)`,
'',
'CONSTATS (mesures) :',
a.texte || '(aucun)',
'',
a.a_examiner > 0
? (pistes.length ? ['PISTES DU CONSEILLER IA (avis a verifier, pas des faits) :', ...pistes].join('\n')
: `Pistes du conseiller IA : ${avisDuJour ? 'aucune' : 'pas de reponse ce matin'}.`)
: '',
].join('\n').trim();
await envoyer(texte, `EVCS - revue de la journee${a.a_examiner > 0 ? ` (${a.a_examiner} point(s))` : ''}`);
await journaliser(sql, 'revue_courriel', 'Revue de la journee envoyee par courriel', { jour, avis: !!avisDuJour });
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));
return `+${String(h - 12).padStart(2, '0')}:00`;
}
// ---------------------------------------------------------------------
// Verse dans la base les relevés de cellule deposes par le poste qui porte le
// cable serie. Celui-ci n'ecrit qu'un CSV dans le depot partage : il n'a donc
@@ -2355,6 +2433,9 @@ async function cycle(client) {
rapport: DRY ? 0 : await envoyerRapportDuMatin(sql, client).catch((e) => {
console.error('rapport de nuit :', e.message); return 0;
}),
revue: DRY ? 0 : await revueDeLaJournee(sql, client, st).catch((e) => {
console.error('revue de la journee :', e.message); return 0;
}),
cellules: DRY ? 0 : await ingererCellules(client).catch((e) => {
console.error(`ingestion cellules en echec : ${e.message}`);
return 0;

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env node
// 08/10/2026 (proprietaire) : le conseiller devient RELECTEUR de la journee. Son
// noeud d'ecriture doit pouvoir publier un capteur avec attributs
// (sensor.evcs_advisor_revue) : un item `state_entity` part vers /api/states,
// les autres vers /api/services comme avant. Rejouable (idempotent).
import fs from 'fs';
const F = 'workflows/evcs-advisor-shadow-v1.json';
const wf = JSON.parse(fs.readFileSync(F, 'utf8'));
const n = wf.nodes.find((x) => x.name === 'Execute HA Service Advisor');
const ancien = "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}";
const nouveau = "={{ $json.state_entity ? 'http://10.10.10.17:8123/api/states/' + $json.state_entity : 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}";
if (n.parameters.url === nouveau) { console.log('deja applique'); process.exit(0); }
if (n.parameters.url !== ancien) throw new Error('url inattendue : ' + n.parameters.url);
n.parameters.url = nouveau;
fs.writeFileSync(F, JSON.stringify(wf, null, 2) + '\n');
console.log('patch applique');

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'),
};
}