Files
EVCS/tools/rapport-nuit.mjs
Tallagrand Thierry 20e0b17d6b Le parc remonte au-dessus de 93 %, et la nuit se raconte par courriel
Le plafond a 93 % n etait pas une protection : le BMS autorisait 53,2 V, nous
imposions 51,3, et la cellule la plus haute du pack recent etait 57 mV sous la
premiere alarme. Le garde-fou datait des cellules faibles du pack ancien, hors
service depuis hier — il protegeait un parc qui n est plus la, au prix de
0,68 kWh, soit 12 % de la reserve utile.

Le helper de tension et ses paliers ne sont lus par personne : les valeurs sont
une table codee en dur dans l Auto Charge Manager, qui entre au passage dans le
depot. Seul le profil de la tranche 90-95 % passe a 52,4 V, soit 3,493 V par
cellule, sous la tension nominale que Pylontech annonce. Verifie dans la
minute : parc de 51,12 a 52,33 V, courant de -5,3 a +16,1 A, aucune alarme.

Et le rapport de nuit part par courriel a 6 h 47. Il ne vit pas dans une
session Claude, qui s arrete avec l ecran, mais dans le service
d historisation, seul composant allume en permanence — il n y avait rien a
surveiller, tout est deja enregistre, il manquait une lecture. Le texte est
ecrit pour etre entendu : pas de tableau, des phrases courtes, des virgules
decimales. Canal : le webhook n8n qui existait deja, teste de bout en bout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-06 15:18:29 +02:00

221 lines
10 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.
// ---------------------------------------------------------------------
export async function texteRapport(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);
const p = []; // paragraphes
const dire = (...l) => p.push(l.join(' '));
const dateLue = fin.toLocaleDateString('fr-FR',
{ timeZone: 'Europe/Paris', weekday: 'long', day: 'numeric', month: 'long' });
dire(`Rapport de la nuit du ${dateLue}.`);
// ------------------------------------------------ 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');
const { rows: [e] } = await q(
`WITH mn AS (SELECT generate_series($1::timestamptz, $2::timestamptz, interval '1 minute') t),
j AS (SELECT mn.t,
(SELECT valeur FROM mesure m WHERE m.cle = 'sensor.victron_grid_power_l1'
AND m.ts <= mn.t ORDER BY m.ts DESC LIMIT 1) g,
(SELECT valeur FROM mesure m WHERE m.cle = 'sensor.victron_evcs_ac_power'
AND m.ts <= mn.t ORDER BY m.ts DESC LIMIT 1) v,
(SELECT valeur FROM mesure m WHERE m.cle = 'sensor.victron_battery_power'
AND m.ts <= mn.t ORDER BY m.ts DESC LIMIT 1) b
FROM mn)
SELECT coalesce(sum(g) FILTER (WHERE g > 0), 0) / 60000.0 AS importe,
coalesce(-sum(g) FILTER (WHERE g < 0), 0) / 60000.0 AS injecte,
coalesce(sum(v) FILTER (WHERE v > 0), 0) / 60000.0 AS voiture,
coalesce(sum(b) FILTER (WHERE b > 0), 0) / 60000.0 AS batterie_chargee,
count(*) FILTER (WHERE g < 0) AS points_negatifs,
min(g) AS pire_injection
FROM j`, [debut, fin]);
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.`);
}
// ------------------------------------------------ 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]);
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.');
}
}
// ------------------------------------------------ la voiture
const { rows: sessions } = await q(
`SELECT debut, vehicule, energie_kwh, energie_compteur_kwh, energie_station_kwh
FROM session_charge WHERE debut >= $1 AND debut < $2 ORDER BY debut`, [debut, fin]);
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.`);
}
}
// ------------------------------------------------ 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]);
// ⚠️ 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.');
}
// ------------------------------------------------ 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]);
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]');
}
}