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
This commit is contained in:
Tallagrand Thierry
2026-09-06 15:18:29 +02:00
parent a7d321f720
commit 20e0b17d6b
6 changed files with 896 additions and 1 deletions

View File

@@ -4794,6 +4794,103 @@ Pour memoire : l'acces SSH par cle a 10.10.10.22 fonctionne et `sudo` y est sans
mot de passe — la demande de validation avant un `sudo` releve de la regle posee
par le proprietaire, pas d'une incapacite.
### Le plafond a 93 % de SOC etait notre reglage, pas une protection
Mesure du 06/09 a 15:01, quatre modules du pack recent :
| | |
|---|---:|
| ce que le BMS AUTORISE (`maximum_allowed_charging_voltage`) | **53,2 V** |
| ce que nous IMPOSIONS | **51,3 V**, soit 3,42 V par cellule |
| cellules du pack 2 | 3 386 a 3 423 mV, ecart 25-30 mV par module |
| premiere alarme haute du BMS | 3 480 mV — la plus haute cellule 57 mV en dessous |
Aucune alarme, et le BMS autorisait encore 40 A de charge : c'etait la consigne
de tension qui plafonnait le SOC. Origine : la gestion « tension de cellule
elevee » posee pour proteger les cellules faibles du pack ANCIEN (cellule 12 du
...0322 a 3 649 mV, cellule 15 du ...0324), **hors service depuis le 05/09**.
Le garde-fou protegeait un parc qui n'est plus la, au prix d'environ
**0,68 kWh** — 12 % de l'energie utile a quatre modules, precisement ce qui
manque en fin de nuit. Un Pylontech recale en outre sa jauge de SOC sur une
charge pleine : ne jamais y arriver la fait deriver.
⚠️ **`input_number.victron_battery_charge_voltage_normal_v` ET SES PALIERS
hcv_step1/step2 NE SONT LUS PAR PERSONNE.** Verifie : le nom n'apparait nulle
part dans l'Auto Charge Manager. Les tensions sont une table CODEE EN DUR dans
le node « Compute Charge Targets » — meme famille que le toggle
`allow_grid_hc_night`, parse mais cable nulle part jusqu'a P7. La vraie
protection est le BMS lui-meme, qui a demontre le 05/09 une reaction en CINQ
SECONDES (cellule au-dessus de 3 550 mV -> plafond de courant effondre) ; elle
reste entiere.
Table des profils jour de l'ACM, par SOC :
| SOC | profil | courant | tension |
|---|---|---:|---:|
| < 80 | pv_boost | 130 A | 51,5 V |
| 80-90 | pv_mid | 110 A | 51,2 V |
| **90-95** | **pv_topoff** | 80 A | **51,3 -> 52,4 V** |
| >= 95 | pv_hold | 30 A | 50,6 V |
Seul `pv_topoff` est modifie : c'est la tranche ou ca plafonnait. `pv_boost` et
`pv_mid` sont limites par le COURANT, pas par la tension. `pv_hold` n'est pas
touche — redescendre a 50,6 V une fois le haut atteint est le bon comportement
pour du LiFePO4, on ne veut pas y sejourner.
52,4 V = **3,493 V par cellule**, SOUS la tension de charge nominale annoncee
par Pylontech pour l'US2000C (52,5 V) et bien sous les 53,2 V que ce BMS
autorise. Avec 30 mV d'ecart entre cellules, la plus haute atteint ~3,51 V,
encore 40 mV sous le seuil de reduction.
**Effet immediat, verifie a 15:14** : consigne DVCC 52,4 V, tension du parc
51,12 -> **52,33 V**, courant batterie **-5,3 -> +16,1 A**, SOC 93 -> 94 %,
`tension_de_cellule_elevee` toujours `no_alarm`, plafond BMS inchange a 40 A.
Le parc reprend de la charge.
Patch rejouable : `tools/patch-20260906c-tension-de-charge-52v4.mjs`. L'Auto
Charge Manager entre au passage dans le depot :
`workflows/victron-auto-charge-manager-v1.2.json` — troisieme workflow
structurant, il n'etait suivi nulle part. Il n'a **pas** de suite de tests.
### Rapport de nuit par courriel — et pourquoi il ne vit pas dans ma session
Le proprietaire le lit en voiture, en se le faisant lire a voix haute. D'ou la
forme : pas de tableau, pas de liste a tirets, phrases courtes, et les nombres
ecrits comme on les prononce (« 4,2 kilowattheures », virgule et non point).
⚠️ **Ce qui ne marche PAS pour surveiller la nuit** : `CronCreate` est en
memoire dans la session Claude, ne se declenche que si le REPL est vivant et
inactif, et disparait avec la session. Un sous-agent meurt avec elle aussi. Le
crontab pose le 05/09 est sur le PC Ubuntu du proprietaire et ne lance que le
releve des cellules.
Et il n'y a **rien a surveiller** : le service d'historisation tourne toutes les
soixante secondes sur une machine allumee en permanence et enregistre deja tout.
Ce qui manquait n'etait pas de la surveillance mais une LECTURE. Le rapport vit
donc DANS le service (`tools/rapport-nuit.mjs`, appele par
`historise-evcs.mjs`), envoye a 6 h 47 avec rattrapage jusqu'a 10 h.
Idempotence par une ligne `evenement` de genre `rapport` — aucun fichier
d'etat, et redemarrer le conteneur ne renvoie pas le courriel.
Canal : le webhook n8n **deja existant et actif** « Webhook Email solar v2 »
(`https://n8n.airtria.pro/webhook/solar-email`, id `vfPbPFtfZt58HOt2`), en GET
avec `to`, `subject`, `message`. Aucun identifiant nouveau : il porte un compte
SMTP configure. ⚠️ Il declare POST dans ses parametres mais n'est enregistre
qu'en GET — un POST renvoie 404. Teste de bout en bout le 06/09 a 15:10,
execution n8n en succes.
Chemins ecartes en route, pour ne pas les re-explorer : `notify` de Home
Assistant n'a que les applications mobiles, aucun SMTP ; `synodsmnotify` refuse
le texte libre (« is neither mail string key nor i18n format ») ;
`notification_send` se bloque ; `sendmail` de DSM rend 0 sans journal, donc sans
preuve de remise ; et DSM n'a **pas** node, donc un cron hote ne peut rien
executer.
Le rapport dit : les fermetures du contacteur et l'energie importee, la
trajectoire du SOC avec l'heure du minimum et l'alerte si le plancher a ete
touche, les sessions de charge par vehicule, l'injection residuelle — **avec un
jugement conditionne a la POINTE et non a l'energie**, parce qu'une pointe a
2 819 W pendant deux minutes n'a rien d'anodin meme si le total reste faible —
et les incidents techniques.
### Reste ouvert
- Detecteur de gel dans le service d'historisation : une grandeur qui ne bouge
pas alors qu'une autre du meme bilan bouge = alerte. Non fait.

View File

@@ -428,3 +428,15 @@ heure_reelle,etat,courant_mA,c1_mV,c2_mV,c3_mV,c4_mV,c5_mV,c6_mV,c7_mV,c8_mV,c9_
2026-09-06T12:41:08.607Z,Y230202C30371425,,3413,3415,3410,3419,3413,3409,3414,3410,3415,3415,3415,3415,3397,3414,3405,22
2026-09-06T12:41:08.702Z,Y230202C30371431,,3421,3419,3419,3419,3420,3420,3419,3419,3420,3393,3421,3419,3419,3419,3420,28
2026-09-06T12:41:08.828Z,Y230202C30371427,,3423,3416,3421,3418,3402,3422,3417,3409,3422,3422,3420,3420,3425,3426,3407,24
2026-09-06T12:51:08.932Z,Y230202C30371426,,3419,3418,3419,3418,3419,3418,3418,3419,3390,3419,3419,3418,3419,3418,3418,29
2026-09-06T12:51:09.059Z,Y230202C30371425,,3422,3422,3417,3427,3422,3418,3422,3418,3423,3424,3424,3423,3404,3422,3414,23
2026-09-06T12:51:09.155Z,Y230202C30371431,,3414,3415,3414,3415,3416,3414,3415,3414,3415,3388,3414,3415,3415,3415,3416,28
2026-09-06T12:51:09.282Z,Y230202C30371427,,3420,3414,3419,3416,3399,3418,3414,3407,3420,3421,3417,3418,3423,3424,3404,25
2026-09-06T13:01:09.386Z,Y230202C30371426,,3413,3414,3415,3415,3413,3414,3414,3415,3386,3414,3414,3414,3415,3415,3413,29
2026-09-06T13:01:09.485Z,Y230202C30371425,,3411,3414,3408,3418,3411,3407,3413,3408,3414,3413,3413,3413,3393,3412,3403,25
2026-09-06T13:01:09.612Z,Y230202C30371431,,3423,3423,3422,3422,3422,3423,3423,3422,3423,3393,3423,3423,3422,3423,3422,30
2026-09-06T13:01:09.735Z,Y230202C30371427,,3419,3412,3417,3413,3394,3417,3413,3405,3417,3416,3415,3417,3421,3421,3400,27
2026-09-06T13:11:09.817Z,Y230202C30371426,,3427,3425,3427,3427,3426,3427,3425,3427,3397,3426,3427,3425,3427,3426,3426,30
2026-09-06T13:11:09.944Z,Y230202C30371425,,3328,3327,3327,3328,3328,3327,3327,3326,3327,3328,3328,3327,3326,3327,3327,2
2026-09-06T13:11:10.099Z,Y230202C30371431,,3323,3323,3324,3324,3325,3323,3323,3324,3324,3322,3324,3323,3324,3324,3325,3
2026-09-06T13:11:10.225Z,Y230202C30371427,,3328,3327,3327,3327,3326,3328,3326,3326,3327,3328,3328,3327,3328,3328,3326,2
1 heure_reelle etat courant_mA c1_mV c2_mV c3_mV c4_mV c5_mV c6_mV c7_mV c8_mV c9_mV c10_mV c11_mV c12_mV c13_mV c14_mV c15_mV ecart_mV
428 2026-09-06T12:41:08.607Z Y230202C30371425 3413 3415 3410 3419 3413 3409 3414 3410 3415 3415 3415 3415 3397 3414 3405 22
429 2026-09-06T12:41:08.702Z Y230202C30371431 3421 3419 3419 3419 3420 3420 3419 3419 3420 3393 3421 3419 3419 3419 3420 28
430 2026-09-06T12:41:08.828Z Y230202C30371427 3423 3416 3421 3418 3402 3422 3417 3409 3422 3422 3420 3420 3425 3426 3407 24
431 2026-09-06T12:51:08.932Z Y230202C30371426 3419 3418 3419 3418 3419 3418 3418 3419 3390 3419 3419 3418 3419 3418 3418 29
432 2026-09-06T12:51:09.059Z Y230202C30371425 3422 3422 3417 3427 3422 3418 3422 3418 3423 3424 3424 3423 3404 3422 3414 23
433 2026-09-06T12:51:09.155Z Y230202C30371431 3414 3415 3414 3415 3416 3414 3415 3414 3415 3388 3414 3415 3415 3415 3416 28
434 2026-09-06T12:51:09.282Z Y230202C30371427 3420 3414 3419 3416 3399 3418 3414 3407 3420 3421 3417 3418 3423 3424 3404 25
435 2026-09-06T13:01:09.386Z Y230202C30371426 3413 3414 3415 3415 3413 3414 3414 3415 3386 3414 3414 3414 3415 3415 3413 29
436 2026-09-06T13:01:09.485Z Y230202C30371425 3411 3414 3408 3418 3411 3407 3413 3408 3414 3413 3413 3413 3393 3412 3403 25
437 2026-09-06T13:01:09.612Z Y230202C30371431 3423 3423 3422 3422 3422 3423 3423 3422 3423 3393 3423 3423 3422 3423 3422 30
438 2026-09-06T13:01:09.735Z Y230202C30371427 3419 3412 3417 3413 3394 3417 3413 3405 3417 3416 3415 3417 3421 3421 3400 27
439 2026-09-06T13:11:09.817Z Y230202C30371426 3427 3425 3427 3427 3426 3427 3425 3427 3397 3426 3427 3425 3427 3426 3426 30
440 2026-09-06T13:11:09.944Z Y230202C30371425 3328 3327 3327 3328 3328 3327 3327 3326 3327 3328 3328 3327 3326 3327 3327 2
441 2026-09-06T13:11:10.099Z Y230202C30371431 3323 3323 3324 3324 3325 3323 3323 3324 3324 3322 3324 3323 3324 3324 3325 3
442 2026-09-06T13:11:10.225Z Y230202C30371427 3328 3327 3327 3327 3326 3328 3326 3326 3327 3328 3328 3327 3328 3328 3326 2

View File

@@ -29,6 +29,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import pg from 'pg';
import { texteRapport, envoyer } from './rapport-nuit.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ONCE = process.argv.includes('--once');
@@ -650,6 +651,51 @@ async function ecrireSession(sql, client, st) {
return n;
}
// ---------------------------------------------------------------------
// Le rapport de nuit, envoye par courriel une fois par jour.
//
// Pourquoi ici plutot que dans un cron ou dans une session interactive : c'est
// ce service qui est allume en permanence. Une session Claude s'arrete des que
// l'ecran se ferme, et le planificateur qu'elle porte s'arrete avec elle ; un
// cron DSM tournerait sur un hote qui n'a meme pas node. Le service, lui, a
// deja la base, le depot et une boucle a la minute.
//
// L'idempotence ne s'appuie sur aucun fichier d'etat : elle se lit dans la
// table `evenement`, qui est de toute facon la trace que l'on veut garder.
// Redemarrer le conteneur ne renvoie donc pas le rapport.
// ---------------------------------------------------------------------
const RAPPORT_HEURE = 6;
const RAPPORT_MINUTE = 47;
async function envoyerRapportDuMatin(sql, client) {
if (!client) return 0;
const parts = 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)]));
const minutesDuJour = parts.hour * 60 + parts.minute;
if (minutesDuJour < RAPPORT_HEURE * 60 + RAPPORT_MINUTE) return 0;
// Fenetre de rattrapage : si le service etait arrete a 6 h 47, il envoie au
// premier cycle suivant, mais pas au milieu de l'apres-midi.
if (minutesDuJour > 10 * 60) return 0;
const jour = `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`;
const { rowCount } = await client.query(
`SELECT 1 FROM evenement WHERE genre = 'rapport'
AND ts >= $1::date AND ts < $1::date + interval '1 day' LIMIT 1`, [jour]);
if (rowCount) return 0;
const texte = await texteRapport(client, new Date());
await envoyer(texte, 'EVCS - rapport de la nuit');
await journaliser(sql, 'rapport', 'Rapport de nuit envoye par courriel',
{ jour, caracteres: texte.length });
console.log(` rapport de nuit envoye (${texte.length} caracteres)`);
return 1;
}
// ---------------------------------------------------------------------
// 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
@@ -717,12 +763,15 @@ async function cycle(client) {
// Ingestion des relevés de cellule deposes par le poste au cable serie.
// Hors essai a blanc : l'insertion est massive et idempotente, la simuler
// n'apprendrait rien.
rapport: DRY ? 0 : await envoyerRapportDuMatin(sql, client).catch((e) => {
console.error('rapport de nuit :', e.message); return 0;
}),
cellules: DRY ? 0 : await ingererCellules(client).catch((e) => {
console.error(`ingestion cellules en echec : ${e.message}`);
return 0;
}),
};
const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement + r.session + r.cellules;
const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement + r.session + r.cellules + (r.rapport || 0);
if (total > 0 || ONCE) {
console.log(
`${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} ` +

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env node
// Le parc plafonnait a 93 % de SOC, et ce n'etait pas une protection.
//
// Mesure du 06/09 a 15:01, quatre modules du pack recent :
// ce que le BMS AUTORISE : 53,2 V (`maximum_allowed_charging_voltage`)
// ce que nous IMPOSIONS : 51,3 V, soit 3,42 V par cellule
// cellules : 3 386 a 3 423 mV, ecart 25-30 mV par module
// premiere alarme haute : 3 480 mV — la cellule la plus haute est 57 mV en dessous
// Aucune alarme, le BMS autorisait encore 40 A de charge : c'est la consigne de
// tension qui plafonnait le SOC, pas la batterie.
//
// Origine : la gestion « tension de cellule elevee » posee pour proteger les
// cellules faibles du pack ANCIEN (cellule 12 du module ...0322 a 3 649 mV,
// cellule 15 du ...0324). Ces modules sont hors service depuis le 05/09. Le
// garde-fou protegeait un parc qui n'est plus la, au prix du haut de la
// reserve : de 93 a ~100 % sur quatre modules, environ 0,68 kWh, soit 12 % de
// l'energie utile — precisement ce qui manque en fin de nuit.
//
// ⚠️ LE HELPER `input_number.victron_battery_charge_voltage_normal_v` (51,3) ET
// SES PALIERS hcv_step1/step2 NE SONT LUS PAR PERSONNE. Verifie : le nom
// n'apparait nulle part dans l'Auto Charge Manager. Les tensions sont une table
// codee en dur dans le node « Compute Charge Targets ». Meme famille que le
// toggle `allow_grid_hc_night`, parse mais cable nulle part jusqu'a P7.
// La VRAIE protection est le BMS lui-meme, qui a demontre le 05/09 une reaction
// en CINQ SECONDES : cellule au-dessus de 3 550 mV -> plafond de courant
// effondre. Elle reste entiere.
//
// 52,4 V = 3,493 V par cellule, SOUS la tension de charge nominale que
// Pylontech annonce pour l'US2000C (52,5 V) et bien sous les 53,2 V que ce BMS
// autorise. Avec 30 mV d'ecart entre cellules, la plus haute atteindra ~3,51 V,
// encore 40 mV sous le seuil qui declenche la reduction de courant.
//
// Seul `pv_topoff` est touche : c'est le profil de la tranche 90-95 % de SOC,
// exactement la ou ca plafonnait. `pv_boost` (SOC < 80) et `pv_mid` (80-90)
// sont limites par le courant, pas par la tension. `pv_hold` (SOC >= 95, 50,6 V)
// n'est PAS touche : redescendre une fois le haut atteint est le bon
// comportement pour du LiFePO4, on ne veut pas y sejourner.
//
// node tools/patch-20260906c-tension-de-charge-52v4.mjs
import fs from 'node:fs';
const F = 'workflows/victron-auto-charge-manager-v1.2.json';
const wf = JSON.parse(fs.readFileSync(F, 'utf8'));
const calc = wf.nodes.find((n) => n.name === 'Compute Charge Targets');
if (!calc) throw new Error('noeud "Compute Charge Targets" introuvable');
let c = calc.parameters.jsCode;
const avant = ' pv_topoff: { current_a: 80, voltage_v: 51.3 },';
const apres = ' pv_topoff: { current_a: 80, voltage_v: 52.4 },';
if (c.includes(apres)) {
console.log(' deja fait');
} else {
if (!c.includes(avant)) throw new Error('ancre pv_topoff introuvable');
c = c.replace(avant, apres);
calc.parameters.jsCode = c;
fs.writeFileSync(F, JSON.stringify(wf, null, 2) + '\n');
console.log(' pv_topoff : 51.3 -> 52.4 V (3,493 V/cellule)');
}

220
tools/rapport-nuit.mjs Normal file
View File

@@ -0,0 +1,220 @@
#!/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]');
}
}

File diff suppressed because one or more lines are too long