// ===================================================================== // 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 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; } // 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')}`, `session HC engagee ${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 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 }, 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), }); } } } 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, 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) })); 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'), }; }