Files
EVCS/tools/rapport-nuit.mjs
Tallagrand Thierry 8d40e0d446 La prevision se juge sur la date qu elle decrit, plus sur son age
Nuit du 07 au 08/09 : 32 calculs du bilan sur 120 en statut degrade, tous entre
00 h et 03 h, avec « aucun croisement PV/conso sur l'horizon, 47,6 kWh a
couvrir ». Le controleur, qui exige statut=ok, retombait sur le palier de 35 % —
d'ou une cible du matin oscillant entre 35, 78,8 et 85 % au lieu de decroitre.

Cause immediate : plus aucune publication de prevision de 23:45 a 04:30. Le
noeud d'appel porte neverError, donc les executions restaient en success ; la
reponse etait « socket hang up », et le noeud d'ecriture vers Home Assistant
rendait 0 item la nuit contre 1 en journee — le .17 etait lui aussi injoignable,
si bien que le message d'echec ne pouvait meme pas etre publie.

Mais le defaut qu'on controle est ailleurs : les courbes sont publiees sans leur
date, et PARSE_FORECAST prend days[0] pour « aujourd'hui ». Vrai a l'instant de
la publication, faux des qu'on passe minuit sans rafraichir. A 00:45 le bilan a
donc lu comme journee en cours celle du 7, dont toutes les heures etaient
passees.

Une courbe est desormais utilisable si elle DECRIT le jour dont on a besoin, pas
si elle est recente : une prevision publiee a 23:45 pour le 8 reste exacte a
03:00, la nuit il n'y a rien a reactualiser. La date part dans
evcs_balance_forecast_day0, et RESOURCE_ESTIMATE distingue trois cas — a jour,
decalee d'un jour (la courbe du lendemain devient celle du jour, l'enveloppe
mesuree reprend la main pour demain), perimee. Helper absent : ancienne garde
d'age, comportement inchange.

Le rapport de nuit signale un trou de plus de deux heures dans les
publications. Il lit la base sur l'autre NAS, seul temoin qui survit quand le
.17 est injoignable. Verifie sur la nuit ecoulee : 285 minutes.

Tests 44 -> 48. Les quatre scenarios partagent la meme heure — 00:45, l'instant
du decrochage — et les memes courbes : seule la date change. Quatre mutations,
chacune sur un jeu different, chaque scenario ayant la sienne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
2026-09-08 09:30:35 +02:00

243 lines
12 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.');
}
// ------------------------------------------------ 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);
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.');
}
// ------------------------------------------------ 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]');
}
}