sensor.evcs_bilan_nuit : le bilan de nuit de l'agent vient du code du rapport du matin

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
This commit is contained in:
Tallagrand Thierry
2026-09-25 12:31:35 +02:00
parent beed8028ca
commit c75d2ffcb8
5 changed files with 184 additions and 52 deletions

View File

@@ -618,5 +618,22 @@ fait tomber les tests (mutation verifiee). Tests 32/32.
code doit donc etre teste sur TOUT le domaine d'entree (ici les sept libelles), code doit donc etre teste sur TOUT le domaine d'entree (ici les sept libelles),
pas seulement sur les cas du banc. pas seulement sur les cas du banc.
⚠️ **Correction du diagnostic ci-dessus** : ce defaut etait reel, mais ce n'est
PAS lui qui a produit la reponse du matin. Transcript recupere par SPARK (vocal,
~11:49, « bilan energetique de la nuit ») : « la JEEP a pris 0,56 kWh (100 %
batterie, zero reseau), elle est arrivee a la cible de 35 % et s'est arretee vers
4 h ». FABRICATION : l'outil de l'agent ne lit que la session EN COURS (aucune a
11:49), et la voiture n'a pas de SOC — les « 35 % » sont la cible de la batterie
maison. J'avais conclu « l'agent a relaye fidelement » sans avoir vu sa reponse.
Correctif : `sensor.evcs_bilan_nuit` (etat `complet` / `en_cours`), calcule par
`donneesNuit()` de `tools/rapport-nuit.mjs` — le MEME code que le courriel de
07:30, extrait sans changer le texte (identique caractere pour caractere sur
cinq nuits : 25, 24, 20, 10 et 06/09). Attributs : vehicules (kWh, dont reseau,
debut, fin), fermetures reseau et import, SOC batterie maison, `soc_voiture` =
« aucun capteur », et `texte_rapport` (le rapport lisible a voix haute). Publie
au premier cycle puis toutes les 5 min. Tests 37/37.
SPARK active aussi la journalisation des entrees/sorties d'outils (retention
courte, jetons masques) : ce matin on n'a pas pu voir ce que l'agent avait lu.
--- ---
*Document mis a jour le : 25/09/2026 — capteur de charge corrige (origine explicite par libelle), incident du matin* *Document mis a jour le : 25/09/2026 — bilan de nuit publie pour l'agent (source unique avec le rapport du matin), incident de 11:49*

View File

@@ -2,7 +2,7 @@
// Les cas PV reprennent les gammes du banc OpenClaw (HANDOFF §77) : G3, G4, G5, G7. // Les cas PV reprennent les gammes du banc OpenClaw (HANDOFF §77) : G3, G4, G5, G7.
// node tests/diagnostics-agent/run.mjs // node tests/diagnostics-agent/run.mjs
import fs from 'node:fs'; import fs from 'node:fs';
import { diagnosticPv, etatCellules, productionJour, chargeSolaire, MPPT_MODES, ORIGINE_PAR_SOURCE } from '../../tools/diagnostics-agent.mjs'; import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, MPPT_MODES, ORIGINE_PAR_SOURCE } from '../../tools/diagnostics-agent.mjs';
const LIM = 'voltage_current_limited', ACT = 'mppt_active'; const LIM = 'voltage_current_limited', ACT = 'mppt_active';
function st({ pv, conso, reseau, soc = 80, courant = 0, edf = 'off', modes }) { function st({ pv, conso, reseau, soc = 80, courant = 0, edf = 'off', modes }) {
@@ -146,6 +146,37 @@ const okLib = libelles.length >= 6 && nonCouverts.length === 0;
if (!okLib) ko++; if (!okLib) ko++;
console.log(`${okLib ? 'OK ' : 'KO '} les ${libelles.length} libelles de source du controleur ont une origine${okLib ? '' : ` (manquent : ${nonCouverts.join(', ') || 'table introuvable'})`}`); console.log(`${okLib ? 'OK ' : 'KO '} les ${libelles.length} libelles de source du controleur ont une origine${okLib ? '' : ` (manquent : ${nonCouverts.join(', ') || 'table introuvable'})`}`);
const n = cas.length + cel.length + prod.length + charge.length + 1; // --- Bilan de la nuit : la nuit reelle du 24 au 25/09 (incident de 11:49) ---
const nuit = {
debut: new Date('2026-09-24T16:00:00Z'), fin: new Date('2026-09-25T08:00:00Z'),
dateLue: 'vendredi 25 septembre',
fermetures: [{ ts: new Date('2026-09-25T04:01:00Z') }],
e: { importe: 2.71, voiture: 7.96 },
b: { depart: 97, bas: 31, heure_bas: new Date('2026-09-25T07:16:00Z'), maintenant: 32 },
sessions: [{ debut: new Date('2026-09-24T23:25:00Z'), fin: new Date('2026-09-25T04:42:00Z'), terminee: true,
vehicule: 'JEEP', energie_kwh: 7.96, energie_compteur_kwh: 7.9, energie_reseau_kwh: 2.2, source_mode: 'Assistance batterie nuit' }],
};
const bn = [
['INCIDENT 11:49 : la session terminee est bien visible, 7,9 kWh dont 2,2 reseau, 01:25 -> 06:42',
bilanNuit(nuit, 'x', new Date('2026-09-25T09:49:00Z')),
(r) => r.etat === 'complet' && r.attributs.vehicules[0].energie_kwh === 7.9 && r.attributs.vehicules[0].dont_reseau_kwh === 2.2
&& r.attributs.vehicules[0].debut === '01:25' && r.attributs.vehicules[0].fin === '06:42'],
['aucun SOC voiture publie ; le 35 % n existe nulle part', bilanNuit(nuit, 'x', new Date('2026-09-25T09:49:00Z')),
(r) => !JSON.stringify(r).includes('35') && /aucun capteur/.test(r.attributs.soc_voiture)],
['reseau : fermeture 06:01, import 2,7', bilanNuit(nuit, 'x', new Date('2026-09-25T09:49:00Z')),
(r) => r.attributs.reseau_fermetures.join() === '06:01' && r.attributs.reseau_importe_kwh === 2.7],
['avant 10:00 -> en_cours, session ouverte sans heure de fin', bilanNuit({ ...nuit,
sessions: [{ ...nuit.sessions[0], terminee: false }] }, 'x', new Date('2026-09-25T03:00:00Z')),
(r) => r.etat === 'en_cours' && r.attributs.vehicules[0].fin === null],
['nuit sans charge -> liste vide', bilanNuit({ ...nuit, sessions: [] }, 'x', new Date('2026-09-25T09:49:00Z')),
(r) => r.attributs.vehicules.length === 0],
];
for (const [nom, r, test] of bn) {
const ok = test(r);
if (!ok) { ko++; console.log(JSON.stringify(r)); }
console.log(`${ok ? 'OK ' : 'KO '} ${nom}`);
}
const n = cas.length + cel.length + prod.length + charge.length + 1 + bn.length;
console.log(`\n${n - ko}/${n}`); console.log(`\n${n - ko}/${n}`);
process.exit(ko ? 1 : 0); process.exit(ko ? 1 : 0);

View File

@@ -266,3 +266,49 @@ export function chargeSolaire(st) {
return r('en_attente', return r('en_attente',
`Voiture branchee mais pas en charge. Le controleur dit : ${raison || 'sans raison publiee'} (source : ${source}).`); `Voiture branchee mais pas en charge. Le controleur dit : ${raison || 'sans raison publiee'} (source : ${source}).`);
} }
// ---------------------------------------------------------------------
// Bilan de la nuit — les donnees du rapport du matin, pour l'agent
// ---------------------------------------------------------------------
// Incident du 25/09 ~11:49 : a « bilan energetique de la nuit », l'agent, qui
// n'avait de source que pour la session EN COURS (aucune a cette heure), a
// invente « 0,56 kWh, zero reseau, arrivee a la cible de 35 %, arretee vers
// 4 h ». Realite : JEEP 7,9 kWh dont 2,2 reseau, 01:25 -> 06:42. Les « 35 % »
// etaient la cible de la BATTERIE MAISON ; la voiture n'a aucun capteur de SOC.
// On publie donc ce que calcule tools/rapport-nuit.mjs (donneesNuit), le meme
// code que le courriel de 07:30 : les deux ne peuvent pas diverger.
// La nuit decrite va de 18:00 la veille a 10:00 ce matin ; avant 10:00 elle est
// `en_cours` (fenetre plafonnee a maintenant, rien d'extrapole).
const hParis = (t) => (t ? new Date(t).toLocaleTimeString('fr-FR',
{ timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }) : null);
const r1 = (x) => (x === null || x === undefined || !Number.isFinite(Number(x)) ? null : Math.round(Number(x) * 10) / 10);
export function bilanNuit(d, texte, maintenant = new Date()) {
const enCours = maintenant < d.fin;
const vehicules = (d.sessions || []).map((s) => ({
vehicule: s.vehicule ?? 'non identifie',
debut: hParis(s.debut),
fin: s.terminee ? hParis(s.fin) : null,
terminee: !!s.terminee,
energie_kwh: r1(s.energie_compteur_kwh ?? s.energie_kwh),
dont_reseau_kwh: r1(s.energie_reseau_kwh),
source_controleur: s.source_mode ?? null,
}));
return {
etat: enCours ? 'en_cours' : 'complet',
attributs: {
nuit_du: d.dateLue,
fenetre: `${hParis(d.debut)} la veille -> ${hParis(d.fin)}${enCours ? ' (en cours, arretee a maintenant)' : ''}`,
reseau_fermetures: (d.fermetures || []).map((f) => hParis(f.ts)),
reseau_importe_kwh: r1(d.e?.importe),
voiture_totale_kwh: r1(d.e?.voiture),
vehicules,
batterie_maison_depart_pct: r1(d.b?.depart ?? d.b?.haut),
batterie_maison_plus_bas_pct: r1(d.b?.bas),
batterie_maison_heure_plus_bas: hParis(d.b?.heure_bas),
batterie_maison_maintenant_pct: r1(d.b?.maintenant),
soc_voiture: 'aucun capteur : ne jamais en annoncer un',
texte_rapport: texte,
},
};
}

View File

@@ -29,8 +29,8 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import pg from 'pg'; import pg from 'pg';
import { texteRapport, envoyer } from './rapport-nuit.mjs'; import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs';
import { diagnosticPv, etatCellules, productionJour, chargeSolaire } from './diagnostics-agent.mjs'; import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit } from './diagnostics-agent.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ONCE = process.argv.includes('--once'); const ONCE = process.argv.includes('--once');
@@ -1848,6 +1848,20 @@ async function publierDiagnosticPv(st) {
return 1; return 1;
} }
// Bilan de la nuit pour l'agent vocal : les donnees du rapport du matin (meme
// code, donc memes chiffres que le courriel). Toutes les 5 min suffit.
let bilanNuitPublie = false; // publie des le premier cycle, puis toutes les 5 min
async function publierBilanNuit(client) {
if (DRY || !client) return 0;
const d = await donneesNuit(client);
const bn = bilanNuit(d, texteDepuis(d));
await publierCapteur('sensor.evcs_bilan_nuit', bn.etat, {
friendly_name: 'EVCS — bilan de la derniere nuit', icon: 'mdi:weather-night', ...bn.attributs,
calcule_a: new Date().toISOString(),
});
return 1;
}
async function publierCellulesHelpers(client) { async function publierCellulesHelpers(client) {
if (DRY) return 0; if (DRY) return 0;
// Base injoignable : on publie « muet » plutot que de laisser au capteur son // Base injoignable : on publie « muet » plutot que de laisser au capteur son
@@ -1926,6 +1940,9 @@ async function cycle(client) {
: await publierPrevisionContreReel(client).catch((e) => { : await publierPrevisionContreReel(client).catch((e) => {
console.error(`prevision contre reel : ${e.message}`); return 0; console.error(`prevision contre reel : ${e.message}`); return 0;
}), }),
bilanNuit: (DRY || (bilanNuitPublie && new Date().getMinutes() % 5 !== 1)) ? 0 : await publierBilanNuit(client).then((n) => { bilanNuitPublie = true; return n; }).catch((e) => {
console.error(`bilan de nuit : ${e.message}`); return 0;
}),
rapport: DRY ? 0 : await envoyerRapportDuMatin(sql, client).catch((e) => { rapport: DRY ? 0 : await envoyerRapportDuMatin(sql, client).catch((e) => {
console.error('rapport de nuit :', e.message); return 0; console.error('rapport de nuit :', e.message); return 0;
}), }),

View File

@@ -58,16 +58,16 @@ 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 // 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. // 18 heures la veille a 10 heures ce matin-la.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
export async function texteRapport(client, jour = new Date()) { // ⚠️ 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 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 debut = new Date(fin); debut.setDate(debut.getDate() - 1); debut.setHours(18, 0, 0, 0);
const q = (sql, v = []) => client.query(sql, v); 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 // ------------------------------------------------ le reseau
const { rows: bascules } = await q( const { rows: bascules } = await q(
@@ -101,14 +101,6 @@ export async function texteRapport(client, jour = new Date()) {
(SELECT count(*) FROM g WHERE v < 0) AS points_negatifs, (SELECT count(*) FROM g WHERE v < 0) AS points_negatifs,
(SELECT min(v) FROM g) AS pire_injection`, [debut, fin]); (SELECT min(v) FROM g) AS pire_injection`, [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 // ------------------------------------------------ la batterie
const { rows: [b] } = await q( const { rows: [b] } = await q(
`SELECT max(valeur) AS haut, min(valeur) AS bas, `SELECT max(valeur) AS haut, min(valeur) AS bas,
@@ -125,6 +117,64 @@ export async function texteRapport(client, jour = new Date()) {
FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts >= $1 AND ts < $2`, FROM mesure WHERE cle = 'sensor.victron_battery_soc' AND ts >= $1 AND ts < $2`,
[debut, fin]); [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) { if (b && b.bas !== null) {
dire(`La batterie est partie de ${n0(b.depart ?? b.haut)} pour cent hier soir,`, 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)},`, `elle est descendue au plus bas a ${n0(b.bas)} pour cent vers ${heure(b.heure_bas)},`,
@@ -135,10 +185,7 @@ export async function texteRapport(client, jour = new Date()) {
} }
} }
// ------------------------------------------------ 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) { if (!sessions.length) {
dire('Aucun vehicule n\'a charge cette nuit.'); dire('Aucun vehicule n\'a charge cette nuit.');
} else { } else {
@@ -149,14 +196,6 @@ export async function texteRapport(client, jour = new Date()) {
} }
} }
// ------------------------------------------------ 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 : // ⚠️ 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 // le 6 septembre, une pointe a 2 819 watts pendant deux minutes n'avait rien
@@ -174,20 +213,7 @@ export async function texteRapport(client, jour = new Date()) {
: 'C\'est du bruit de regulation, sans consequence.'); : '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) { if (trouMin >= 120) {
const h = Math.floor(trouMin / 60); const h = Math.floor(trouMin / 60);
const m = trouMin % 60; const m = trouMin % 60;
@@ -196,12 +222,7 @@ export async function texteRapport(client, jour = new Date()) {
+ ' vieillissante pendant ce temps, et la cible de la nuit a pu retomber sur la regle des paliers.'); + ' 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) { if (soucis.length) {
dire(`A signaler : ${soucis.map((s) => s.resume.toLowerCase()).join('. ')}.`); dire(`A signaler : ${soucis.map((s) => s.resume.toLowerCase()).join('. ')}.`);
} else { } else {