Incident 25/09 11:49 : l'agent sans source avait invente 0,56 kWh / zero reseau / 35 % pour une nuit a 7,9 kWh dont 2,2 reseau. donneesNuit() extrait de rapport-nuit.mjs (texte identique sur 5 nuits), publie pour l'agent. Tests 37/37. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z
271 lines
14 KiB
JavaScript
271 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
// =====================================================================
|
|
// RAPPORT DE NUIT — texte simple, destine a etre LU A VOIX HAUTE
|
|
// =====================================================================
|
|
// Le proprietaire le recoit par courriel le matin, souvent en voiture : il se
|
|
// le fait lire. D'ou les contraintes de forme, qui ne sont pas cosmetiques —
|
|
// pas de tableau, pas de tiret de liste, pas d'abreviation, des phrases
|
|
// courtes, et les nombres ecrits comme on les prononce (« 4,2 kilowattheures »
|
|
// et non « 4.2 kWh »).
|
|
//
|
|
// Il n'y a RIEN a surveiller en direct : le service d'historisation tourne
|
|
// toutes les soixante secondes sur une machine allumee en permanence et
|
|
// enregistre deja tout. Ce fichier ne surveille pas, il LIT — et c'est
|
|
// pourquoi il vit dans le service plutot que dans une session interactive,
|
|
// qui, elle, s'arrete des que l'ecran se ferme.
|
|
//
|
|
// Usage :
|
|
// node tools/rapport-nuit.mjs # affiche le rapport
|
|
// node tools/rapport-nuit.mjs --envoyer # l'envoie par courriel
|
|
// node tools/rapport-nuit.mjs --jour 2026-09-05
|
|
// =====================================================================
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import pg from 'pg';
|
|
|
|
const RACINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const WEBHOOK = 'https://n8n.airtria.pro/webhook/solar-email';
|
|
const DESTINATAIRE = 'tallagrand.thierry@gmail.com';
|
|
|
|
function envFichier() {
|
|
const out = {};
|
|
for (const f of [path.join(RACINE, '.env'), '/app/.env']) {
|
|
if (!fs.existsSync(f)) continue;
|
|
for (const l of fs.readFileSync(f, 'utf8').split('\n')) {
|
|
const i = l.indexOf('=');
|
|
if (i > 0 && !l.trimStart().startsWith('#')) out[l.slice(0, i).trim()] = l.slice(i + 1).trim();
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// « 4.2 » -> « 4,2 » : une virgule se lit, un point ne se lit pas.
|
|
const n1 = (v) => (v === null || v === undefined || !Number.isFinite(Number(v)))
|
|
? null : Number(v).toFixed(1).replace('.', ',');
|
|
const n2 = (v) => (v === null || v === undefined || !Number.isFinite(Number(v)))
|
|
? null : Number(v).toFixed(2).replace('.', ',');
|
|
const n0 = (v) => (v === null || v === undefined || !Number.isFinite(Number(v)))
|
|
? null : Math.round(Number(v)).toString();
|
|
|
|
const heure = (d) => new Date(d).toLocaleTimeString('fr-FR',
|
|
{ timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }).replace(':', ' h ');
|
|
|
|
function pluriel(n, sing, plur) { return n <= 1 ? `${n} ${sing}` : `${n} ${plur}`; }
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Le rapport lui-meme. `jour` est la date du MATIN ; la nuit examinee va de
|
|
// 18 heures la veille a 10 heures ce matin-la.
|
|
// ---------------------------------------------------------------------
|
|
// ⚠️ UNE SEULE SOURCE pour le courriel du matin ET l'agent vocal (25/09) :
|
|
// `donneesNuit` fait toutes les lectures, `texteRapport` les met en phrases, et
|
|
// le service d'historisation publie les memes donnees dans
|
|
// sensor.evcs_bilan_nuit. Ce matin-la, l'agent sans source avait invente
|
|
// « 0,56 kWh, zero reseau, arretee vers 4 h » pour une nuit a 7,9 kWh dont 2,2
|
|
// reseau. Les deux sorties ne peuvent plus diverger : meme code, memes chiffres.
|
|
export async function donneesNuit(client, jour = new Date()) {
|
|
const fin = new Date(jour); fin.setHours(10, 0, 0, 0);
|
|
const debut = new Date(fin); debut.setDate(debut.getDate() - 1); debut.setHours(18, 0, 0, 0);
|
|
const q = (sql, v = []) => client.query(sql, v);
|
|
|
|
// ------------------------------------------------ le reseau
|
|
const { rows: bascules } = await q(
|
|
`SELECT ts, detail->>'apres' AS apres FROM evenement
|
|
WHERE genre = 'reseau' AND resume LIKE 'Contacteur EDF%'
|
|
AND ts >= $1 AND ts < $2 ORDER BY ts`, [debut, fin]);
|
|
const fermetures = bascules.filter((b) => b.apres === 'on');
|
|
|
|
// Integration ponderee par l'intervalle REEL entre points (cap 300 s pour
|
|
// qu'un trou n'extrapole rien), et fenetre plafonnee a now(). Le rapport
|
|
// tourne a 06:47 alors que la fenetre va jusqu'a 10:00 : l'ancienne methode
|
|
// (grille generate_series 1 min + derniere valeur connue + sum/60000)
|
|
// reportait la derniere puissance connue sur les ~3 h FUTURES de la fenetre
|
|
// et surestimait l'import (3,8 kWh reconstitue au lieu de 1,6 mesure, nuit du
|
|
// 24/09, signale par la session SPARK via les compteurs d'energie Victron).
|
|
// NE JAMAIS revenir a sum(x)/60000 sur une grille qui depasse maintenant :
|
|
// elle integre du futur. La ponderation par lead(ts) n'a pas de point apres
|
|
// le dernier echantillon, donc elle n'extrapole jamais.
|
|
const { rows: [e] } = await q(
|
|
`WITH b AS (SELECT $1::timestamptz d, LEAST($2::timestamptz, now()) f),
|
|
g AS (SELECT valeur v, LEAST(EXTRACT(EPOCH FROM (lead(ts) OVER (ORDER BY ts) - ts)), 300) dt
|
|
FROM mesure, b WHERE cle = 'sensor.victron_grid_power_l1' AND ts >= d AND ts < f),
|
|
ev AS (SELECT valeur v, LEAST(EXTRACT(EPOCH FROM (lead(ts) OVER (ORDER BY ts) - ts)), 300) dt
|
|
FROM mesure, b WHERE cle = 'sensor.victron_evcs_ac_power' AND ts >= d AND ts < f),
|
|
ba AS (SELECT valeur v, LEAST(EXTRACT(EPOCH FROM (lead(ts) OVER (ORDER BY ts) - ts)), 300) dt
|
|
FROM mesure, b WHERE cle = 'sensor.victron_battery_power' AND ts >= d AND ts < f)
|
|
SELECT (SELECT coalesce(sum(GREATEST(v,0)*dt) FILTER (WHERE dt IS NOT NULL),0)/3600000.0 FROM g) AS importe,
|
|
(SELECT coalesce(sum(GREATEST(-v,0)*dt) FILTER (WHERE dt IS NOT NULL),0)/3600000.0 FROM g) AS injecte,
|
|
(SELECT coalesce(sum(GREATEST(v,0)*dt) FILTER (WHERE dt IS NOT NULL),0)/3600000.0 FROM ev) AS voiture,
|
|
(SELECT coalesce(sum(GREATEST(v,0)*dt) FILTER (WHERE dt IS NOT NULL),0)/3600000.0 FROM ba) AS batterie_chargee,
|
|
(SELECT count(*) FROM g WHERE v < 0) AS points_negatifs,
|
|
(SELECT min(v) FROM g) AS pire_injection`, [debut, fin]);
|
|
|
|
// ------------------------------------------------ la batterie
|
|
const { rows: [b] } = await q(
|
|
`SELECT max(valeur) AS haut, min(valeur) AS bas,
|
|
(SELECT ts FROM mesure m2 WHERE m2.cle = 'sensor.victron_battery_soc'
|
|
AND m2.ts >= $1 AND m2.ts < $2
|
|
ORDER BY m2.valeur ASC, m2.ts ASC LIMIT 1) AS heure_bas,
|
|
-- Valeur AU DEBUT de la fenetre, pas le maximum : dire « partie de »
|
|
-- en donnant le maximum de la nuit ferait commencer le recit par un
|
|
-- chiffre atteint plus tard.
|
|
(SELECT valeur FROM mesure m1 WHERE m1.cle = 'sensor.victron_battery_soc'
|
|
AND m1.ts <= $1 ORDER BY m1.ts DESC LIMIT 1) AS depart,
|
|
(SELECT valeur FROM mesure m3 WHERE m3.cle = 'sensor.victron_battery_soc'
|
|
AND m3.ts < $2 ORDER BY m3.ts DESC LIMIT 1) AS maintenant
|
|
FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts >= $1 AND ts < $2`,
|
|
[debut, fin]);
|
|
|
|
// ------------------------------------------------ la voiture
|
|
const { rows: sessions } = await q(
|
|
`SELECT debut, fin, terminee, vehicule, energie_kwh, energie_compteur_kwh, energie_station_kwh,
|
|
energie_reseau_kwh, source_mode
|
|
FROM session_charge WHERE debut >= $1 AND debut < $2 ORDER BY debut`, [debut, fin]);
|
|
// ------------------------------------------------ ce qu'on a deploye le 6
|
|
const { rows: [d] } = await q(
|
|
`SELECT count(*) FILTER (WHERE etat = 'hold_on_charge_encore_elevee') AS maintiens,
|
|
count(*) FILTER (WHERE etat LIKE 'turn_on_charge%') AS protections
|
|
FROM decision WHERE ts >= $1 AND ts < $2`, [debut, fin]);
|
|
const { rows: [h] } = await q(
|
|
`SELECT count(*) AS n FROM evenement
|
|
WHERE ts >= $1 AND ts < $2 AND resume LIKE '%charge maison%'`, [debut, fin]);
|
|
|
|
// ------------------------------------------------ prevision solaire
|
|
// Une panne de prevision est invisible autrement : les nodes n8n portent
|
|
// `onError: continueRegularOutput`, donc les executions restent « success »
|
|
// meme quand l'appel echoue, et quand Home Assistant est lui aussi
|
|
// injoignable le message d'echec ne peut meme pas etre publie. Le service
|
|
// d'historisation, lui, tourne sur l'autre NAS : c'est le seul temoin qui
|
|
// survit. Releve de la nuit du 07 au 08/09 : 4 h 45 sans une seule
|
|
// publication, et personne ne l'a su avant le lendemain.
|
|
const { rows: [prev] } = await q(
|
|
`SELECT round(EXTRACT(EPOCH FROM max(ecart)) / 60) AS trou
|
|
FROM (SELECT publie_a - lag(publie_a) OVER (ORDER BY publie_a) AS ecart
|
|
FROM prevision WHERE publie_a >= $1 AND publie_a < $2) t`,
|
|
[debut, fin]);
|
|
const trouMin = Number(prev?.trou ?? 0);
|
|
// ------------------------------------------------ anomalies
|
|
const { rows: soucis } = await q(
|
|
`SELECT resume FROM evenement WHERE ts >= $1 AND ts < $2
|
|
AND (resume ILIKE '%injoignable%' OR resume ILIKE '%redemarr%'
|
|
OR resume ILIKE '%erreur%' OR resume ILIKE '%echec%')
|
|
ORDER BY ts`, [debut, fin]);
|
|
|
|
const dateLue = fin.toLocaleDateString('fr-FR',
|
|
{ timeZone: 'Europe/Paris', weekday: 'long', day: 'numeric', month: 'long' });
|
|
return { debut, fin, dateLue, fermetures, e, b, sessions, d, h, trouMin, soucis };
|
|
}
|
|
|
|
export async function texteRapport(client, jour = new Date()) {
|
|
return texteDepuis(await donneesNuit(client, jour));
|
|
}
|
|
|
|
export function texteDepuis({ fermetures, e, b, sessions, trouMin, soucis, dateLue }) {
|
|
const p = []; // paragraphes
|
|
const dire = (...l) => p.push(l.join(' '));
|
|
dire(`Rapport de la nuit du ${dateLue}.`);
|
|
|
|
if (!fermetures.length) {
|
|
dire('Le contacteur est reste ouvert toute la nuit : aucune energie achetee au reseau.');
|
|
} else {
|
|
dire(`Le reseau a ete ferme ${pluriel(fermetures.length, 'fois', 'fois')},`,
|
|
`a ${fermetures.map((f) => heure(f.ts)).join(', puis a ')}.`,
|
|
`Au total ${n1(e.importe)} kilowattheures importes.`);
|
|
}
|
|
|
|
|
|
if (b && b.bas !== null) {
|
|
dire(`La batterie est partie de ${n0(b.depart ?? b.haut)} pour cent hier soir,`,
|
|
`elle est descendue au plus bas a ${n0(b.bas)} pour cent vers ${heure(b.heure_bas)},`,
|
|
`et elle est ce matin a ${n0(b.maintenant)} pour cent.`);
|
|
if (Number(b.bas) <= 20) {
|
|
dire('Attention : elle a touche son plancher de vingt pour cent,',
|
|
'donc le reseau a pris le relais de lui-meme.');
|
|
}
|
|
}
|
|
|
|
|
|
if (!sessions.length) {
|
|
dire('Aucun vehicule n\'a charge cette nuit.');
|
|
} else {
|
|
for (const s of sessions) {
|
|
const kwh = s.energie_compteur_kwh ?? s.energie_kwh;
|
|
dire(`Le vehicule ${s.vehicule ?? 'non identifie'} a charge a partir de ${heure(s.debut)},`,
|
|
`et il a pris ${n1(kwh)} kilowattheures.`);
|
|
}
|
|
}
|
|
|
|
|
|
// ⚠️ Ne jamais qualifier l'injection de negligeable sans regarder la pointe :
|
|
// le 6 septembre, une pointe a 2 819 watts pendant deux minutes n'avait rien
|
|
// d'anodin, alors que l'energie totale restait faible.
|
|
if (Number(e.points_negatifs) === 0) {
|
|
dire('Cote qualite du raccordement, aucune minute d\'injection vers le reseau.');
|
|
} else {
|
|
const pointe = Math.abs(Number(e.pire_injection));
|
|
dire(`Il reste ${pluriel(Number(e.points_negatifs), 'minute', 'minutes')} ou le compteur`,
|
|
`est passe en negatif, au plus fort ${n0(pointe)} watts,`,
|
|
`soit ${n0(e.injecte * 1000)} wattheures en tout.`,
|
|
pointe >= 1000
|
|
? 'Une pointe de cette taille merite un coup d\'oeil : elle vient d\'une charge'
|
|
+ ' qui disparait d\'un coup alors que la batterie ne peut plus absorber.'
|
|
: 'C\'est du bruit de regulation, sans consequence.');
|
|
}
|
|
|
|
|
|
if (trouMin >= 120) {
|
|
const h = Math.floor(trouMin / 60);
|
|
const m = trouMin % 60;
|
|
dire(`Attention, la prevision solaire n'a pas ete rafraichie pendant ${h} heure${h > 1 ? 's' : ''}`
|
|
+ `${m ? ` et ${m} minutes` : ''}. Le bilan a donc travaille sur une courbe`
|
|
+ ' vieillissante pendant ce temps, et la cible de la nuit a pu retomber sur la regle des paliers.');
|
|
}
|
|
|
|
|
|
if (soucis.length) {
|
|
dire(`A signaler : ${soucis.map((s) => s.resume.toLowerCase()).join('. ')}.`);
|
|
} else {
|
|
dire('Aucun incident technique pendant la nuit.');
|
|
}
|
|
|
|
// ------------------------------------------------ la conclusion
|
|
const vider = Number(b?.bas ?? 100) <= 20;
|
|
const cher = Number(e.importe) > 8;
|
|
dire(vider || cher
|
|
? 'En resume, la nuit a demande plus au reseau que prevu. Le detail est dans le depot.'
|
|
: 'En resume, la nuit s\'est passee comme prevu.');
|
|
|
|
return p.join('\n\n');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
export async function envoyer(texte, sujet) {
|
|
const url = `${WEBHOOK}?${new URLSearchParams(
|
|
{ to: DESTINATAIRE, subject: sujet, message: texte })}`;
|
|
const r = await fetch(url);
|
|
if (!r.ok) throw new Error(`webhook courriel : HTTP ${r.status}`);
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
const estCli = process.argv[1] && process.argv[1].endsWith('rapport-nuit.mjs');
|
|
if (estCli) {
|
|
const env = envFichier();
|
|
const arg = (n) => { const i = process.argv.indexOf(n); return i > 0 ? process.argv[i + 1] : null; };
|
|
const jour = arg('--jour') ? new Date(arg('--jour') + 'T12:00:00') : new Date();
|
|
const client = new pg.Client({
|
|
host: process.env.PGHOST || '10.10.10.22',
|
|
port: Number(process.env.PGPORT || 5433),
|
|
database: 'evcs', user: 'evcs_lecture', password: env.PG_LECTURE_PASSWORD,
|
|
});
|
|
await client.connect();
|
|
const texte = await texteRapport(client, jour);
|
|
await client.end();
|
|
console.log(texte);
|
|
if (process.argv.includes('--envoyer')) {
|
|
await envoyer(texte, 'EVCS - rapport de la nuit');
|
|
console.log('\n[courriel envoye]');
|
|
}
|
|
}
|