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:
97
HANDOFF.md
97
HANDOFF.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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' })} ` +
|
||||
|
||||
60
tools/patch-20260906c-tension-de-charge-52v4.mjs
Normal file
60
tools/patch-20260906c-tension-de-charge-52v4.mjs
Normal 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
220
tools/rapport-nuit.mjs
Normal 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]');
|
||||
}
|
||||
}
|
||||
457
workflows/victron-auto-charge-manager-v1.2.json
Normal file
457
workflows/victron-auto-charge-manager-v1.2.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user