Capteur de charge : origine explicite par libelle (incident 25/09, batterie publiee en solaire)

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:05:04 +02:00
parent 13d9640130
commit beed8028ca
3 changed files with 65 additions and 6 deletions

View File

@@ -602,5 +602,21 @@ deja ailleurs (controleur EVCS), l'agent la RELAIE, il ne la recalcule jamais.
Limite connue : si le service d'historisation meurt, les quatre capteurs se Limite connue : si le service d'historisation meurt, les quatre capteurs se
figent ; l'agent verifie `sensor.evcs_historisation_heartbeat`. figent ; l'agent verifie `sensor.evcs_historisation_heartbeat`.
### Incident du matin 25/09 — le capteur de charge disait « solaire » la nuit
Le proprio : « l'agent a delire ce matin sur la charge voiture ». Cause = MON
capteur, pas l'agent. `chargeSolaire` classait toute source qui ne commence pas
par « Reseau » comme solaire : de **01:25 a 06:00**, `sensor.evcs_charge_solaire`
a publie `en_charge_solaire` alors que la JEEP chargeait sur la BATTERIE
(« Assistance batterie nuit », puis reseau force de 06:02 a 06:42 ; session
7,9 kWh dont 2,2 reseau). L'agent a relaye fidelement une fausse verite.
Correctif : correspondance EXPLICITE libelle par libelle de `sourceModeLabel`
(HA_SERVICE_CALLS_OUTPUT.js), nouveaux etats `en_charge_batterie`,
`en_charge_sonde`, et `en_charge` sans origine pour un libelle inconnu. Test
garde-fou qui lit la table DANS le code du controleur : un libelle ajoute la-bas
fait tomber les tests (mutation verifiee). Tests 32/32.
⚠️ Lecon : un capteur-verdict deplace la fragilite de la fiche vers le code ; le
code doit donc etre teste sur TOUT le domaine d'entree (ici les sept libelles),
pas seulement sur les cas du banc.
--- ---
*Document mis a jour le : 25/09/2026 — agent vocal : la partie energie relaie des capteurs-verdict (banc 15/15), cadrage multi-agents* *Document mis a jour le : 25/09/2026 — capteur de charge corrige (origine explicite par libelle), incident du matin*

View File

@@ -1,7 +1,8 @@
// Tests des diagnostics publies pour l'agent vocal (tools/diagnostics-agent.mjs). // Tests des diagnostics publies pour l'agent vocal (tools/diagnostics-agent.mjs).
// 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 { diagnosticPv, etatCellules, productionJour, chargeSolaire, MPPT_MODES } from '../../tools/diagnostics-agent.mjs'; import fs from 'node:fs';
import { diagnosticPv, etatCellules, productionJour, chargeSolaire, 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 }) {
@@ -114,6 +115,14 @@ const charge = [
(r) => r.etat === 'en_attente' && r.attributs.raison_controleur === 'Excedent insuffisant' && r.attributs.excedent_w === -1854], (r) => r.etat === 'en_attente' && r.attributs.raison_controleur === 'Excedent insuffisant' && r.attributs.excedent_w === -1854],
['en charge sur source solaire -> en_charge_solaire', borne({ p: 3600, source: 'Solaire' }), (r) => r.etat === 'en_charge_solaire'], ['en charge sur source solaire -> en_charge_solaire', borne({ p: 3600, source: 'Solaire' }), (r) => r.etat === 'en_charge_solaire'],
['en charge source reseau -> en_charge_reseau', borne({ p: 3600, source: 'Réseau HC autorisé' }), (r) => r.etat === 'en_charge_reseau'], ['en charge source reseau -> en_charge_reseau', borne({ p: 3600, source: 'Réseau HC autorisé' }), (r) => r.etat === 'en_charge_reseau'],
// Incident du 25/09 01:25-06:00 : JEEP sur batterie la nuit, publie « solaire ».
['INCIDENT 25/09 : assistance batterie nuit -> en_charge_batterie, JAMAIS solaire',
borne({ p: 1198, source: 'Assistance batterie nuit', pv: 0, conso: 900, modes: 'off' }),
(r) => r.etat === 'en_charge_batterie' && !/solaire/i.test(r.attributs.explication.split('(')[0])],
['sonde hybride -> en_charge_sonde', borne({ p: 2300, source: 'Probe hybride' }), (r) => r.etat === 'en_charge_sonde'],
['potentiel solaire -> en_charge_solaire', borne({ p: 2300, source: 'Potentiel solaire' }), (r) => r.etat === 'en_charge_solaire'],
['reseau force -> en_charge_reseau', borne({ p: 3600, source: 'Réseau forcé' }), (r) => r.etat === 'en_charge_reseau'],
['libelle inconnu -> en_charge sans origine devinee', borne({ p: 3600, source: 'nouveau_mode' }), (r) => r.etat === 'en_charge'],
['non branchee -> non_branchee', borne({ branche: 'off' }), (r) => r.etat === 'non_branchee'], ['non branchee -> non_branchee', borne({ branche: 'off' }), (r) => r.etat === 'non_branchee'],
['G7 excedent positif et bride, mais controleur bloque -> en_attente (on RELAIE, on ne rejuge pas)', ['G7 excedent positif et bride, mais controleur bloque -> en_attente (on RELAIE, on ne rejuge pas)',
borne({ pv: 5000, conso: 1500, modes: LIM, source: 'Bloqué', raison: 'Vehicule plein' }), borne({ pv: 5000, conso: 1500, modes: LIM, source: 'Bloqué', raison: 'Vehicule plein' }),
@@ -127,6 +136,16 @@ for (const [nom, etats, test] of charge) {
console.log(`${ok ? 'OK ' : 'KO '} ${nom}`); console.log(`${ok ? 'OK ' : 'KO '} ${nom}`);
} }
const n = cas.length + cel.length + prod.length + charge.length; // Chaque libelle publie par le controleur doit avoir une origine explicite :
// lu dans SON code, pour qu'un libelle ajoute la-bas fasse tomber ce test.
const srcCtrl = fs.readFileSync(new URL('../../src/nodes/HA_SERVICE_CALLS_OUTPUT.js', import.meta.url), 'utf8');
const bloc = srcCtrl.slice(srcCtrl.indexOf('const sourceModeLabel ='), srcCtrl.indexOf('context.sourceMode;', srcCtrl.indexOf('const sourceModeLabel =')));
const libelles = [...bloc.matchAll(/\? '([^']+)'/g)].map((m) => m[1]).filter((l) => l !== 'Bloqué');
const nonCouverts = libelles.filter((l) => !ORIGINE_PAR_SOURCE[l]);
const okLib = libelles.length >= 6 && nonCouverts.length === 0;
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'})`}`);
const n = cas.length + cel.length + prod.length + charge.length + 1;
console.log(`\n${n - ko}/${n}`); console.log(`\n${n - ko}/${n}`);
process.exit(ko ? 1 : 0); process.exit(ko ? 1 : 0);

View File

@@ -208,6 +208,29 @@ export function productionJour(st, maintenant = new Date()) {
// l'expliquer (excedent, chargeurs brides), sans decider. // l'expliquer (excedent, chargeurs brides), sans decider.
export const SEUIL_CHARGE_W = 300; // meme seuil que les sessions de charge export const SEUIL_CHARGE_W = 300; // meme seuil que les sessions de charge
// ⚠️ Correspondance EXPLICITE, libelle par libelle, de la table
// `sourceModeLabel` de src/nodes/HA_SERVICE_CALLS_OUTPUT.js. Defaut du
// 25/09 : la premiere version classait « tout ce qui n'est pas Reseau » comme
// solaire, et a publie `en_charge_solaire` de 01:25 a 06:00 pendant que la JEEP
// chargeait sur la BATTERIE en pleine nuit (« Assistance batterie nuit »). L'agent
// l'a repete fidelement. Un libelle inconnu donne `en_charge` sans origine :
// on ne devine jamais d'ou vient l'energie.
export const ORIGINE_PAR_SOURCE = {
'Solaire': 'en_charge_solaire',
'Potentiel solaire': 'en_charge_solaire',
'Probe hybride': 'en_charge_sonde',
'Assistance batterie nuit': 'en_charge_batterie',
'Réseau HC autorisé': 'en_charge_reseau',
'Réseau forcé': 'en_charge_reseau',
};
const PHRASE_ORIGINE = {
en_charge_solaire: 'sur le solaire',
en_charge_sonde: 'en essai court pour provoquer la production solaire',
en_charge_batterie: 'sur la batterie de la maison (assistance de nuit)',
en_charge_reseau: 'sur le reseau EDF',
en_charge: 'depuis une source que le controleur ne precise pas',
};
export function chargeSolaire(st) { export function chargeSolaire(st) {
const txt = (id) => { const s = st.get(id)?.state; return s === undefined || s === 'unavailable' || s === 'unknown' ? null : String(s); }; const txt = (id) => { const s = st.get(id)?.state; return s === undefined || s === 'unavailable' || s === 'unknown' ? null : String(s); };
const branche = txt('binary_sensor.victron_evcs_vehicle_connected'); const branche = txt('binary_sensor.victron_evcs_vehicle_connected');
@@ -235,9 +258,10 @@ export function chargeSolaire(st) {
return r('non_branchee', 'Aucun vehicule branche. Une fois branche, le controleur decidera lui-meme.'); return r('non_branchee', 'Aucun vehicule branche. Une fois branche, le controleur decidera lui-meme.');
} }
if (puissance > SEUIL_CHARGE_W) { if (puissance > SEUIL_CHARGE_W) {
const reseau = /^R[ée]seau/.test(source); const etat = ORIGINE_PAR_SOURCE[source] || 'en_charge';
return r(reseau ? 'en_charge_reseau' : 'en_charge_solaire', return r(etat,
`La voiture charge (${Math.round(puissance)} W), source decidee par le controleur : ${source}.`); `La voiture charge (${Math.round(puissance)} W) ${PHRASE_ORIGINE[etat]} ` +
`(source decidee par le controleur : ${source}).`);
} }
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}).`);