Files
EVCS/tools/diagnostics-agent.mjs
Tallagrand Thierry 08a1cee1b1 Releve du pack 1 (US5000) sur yd-5CG6110DG0 : service, regle udev, suffixe de fichier
Le cable du pack 1 est a puce Prolific, sans pilote sur DSM : lu par un
portable a noyau recent. --suffixe pack1 separe les CSV des deux lecteurs ;
evcs_cellules regarde 15 min (deux lecteurs non synchronises) et publie
modules_lus (9 attendus).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uJ47NxgfpGnuuMr3f2n7Z
2026-09-26 00:41:47 +02:00

319 lines
16 KiB
JavaScript

// =====================================================================
// Diagnostics publies pour l'agent vocal OpenClaw — calcules ICI, pas par lui.
// =====================================================================
//
// POURQUOI (25/09/2026). Le banc PV d'OpenClaw a montre que la fiche de l'agent
// est sur le fil du rasoir : 96 caracteres ajoutes dans le bloc CELLULE, sans
// aucun rapport avec la substitution, ont fait tomber G5 (substitution EDF) de
// 7/7 a 1/7. Une regle qu'un ajout sans rapport fait basculer cassera au
// prochain ajout. On sort donc les deux jugements du modele : ils sont calcules
// ici, testes (tests/diagnostics-agent/run.mjs), et l'agent n'a plus qu'a LIRE
// un verdict et l'expliquer.
//
// Fonctions PURES (etats HA en entree, objet en sortie) : aucun appel reseau,
// pour que les tests rejouent exactement ce qui tourne.
// =====================================================================
// Les sept chargeurs lus par l'agent (MANUEL §0, HANDOFF §77). Le RS 450/200
// expose un mode d'appareil ET un mode par tracker : on les compte tous, comme
// le banc.
export const MPPT_MODES = [
'sensor.airtria_mppt1_mppt_operation_mode',
'sensor.mppt_450_200_hq2449yr9pt_mppt_operation_mode',
'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_1_operation_mode',
'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_2_operation_mode',
'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_3_operation_mode',
'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_4_operation_mode',
'sensor.v1_mppt_operation_mode',
];
// Bruit de mesure du compteur reseau, PAS un reglage de decision : contacteur
// ferme, l'ESS preleve quelques dizaines de watts meme a consigne nulle. En
// dessous, « import ≈ 0 » au sens de la regle du 31/08.
export const IMPORT_BRUIT_W = 100;
// Sous cette puissance PV, il n'y a pas de production a discuter (nuit, aube).
export const PV_NUIT_W = 50;
function nombre(st, id) {
const s = st.get(id);
if (!s) return null;
const v = Number(s.state);
return Number.isFinite(v) ? v : null;
}
// ---------------------------------------------------------------------
// G5 — bridage normal, vraie baisse, ou substitution par EDF ?
// ---------------------------------------------------------------------
// Regle du 31/08 (HANDOFF §26-§31, §77) : un chargeur qui se retient prouve
// qu'il reste de la ressource refusee. Si le reseau n'importe rien, c'est le
// bridage normal d'une installation sans injection. S'il importe, EDF fournit
// ce que le solaire aurait pu donner : c'est la SUBSTITUTION, l'anomalie.
// L'import se verifie AVANT de conclure au bridage, par construction : c'est
// l'ordre des tests ci-dessous.
export function diagnosticPv(st) {
const pv = nombre(st, 'sensor.victron_pv_power_system');
const conso = nombre(st, 'sensor.victron_consumption_power_l1');
const reseau = nombre(st, 'sensor.victron_grid_power_l1'); // > 0 = import
const soc = nombre(st, 'sensor.victron_battery_soc');
const courant = nombre(st, 'sensor.victron_battery_current'); // > 0 = charge
const edf = st.get('switch.edf_general_ok_switch')?.state ?? null;
const modes = MPPT_MODES
.map((id) => String(st.get(id)?.state ?? '').trim().toLowerCase())
.filter((m) => m !== '' && m !== 'unavailable' && m !== 'unknown');
const limites = modes.filter((m) => m.includes('limited')).length;
const actifs = modes.filter((m) => m === 'mppt_active').length;
const importW = reseau === null ? null : Math.max(0, reseau);
const attributs = {
pv_w: pv, conso_w: conso, import_w: importW, soc_pct: soc,
courant_batterie_a: courant, contacteur_edf: edf,
mppt_lisibles: modes.length, mppt_bridés: limites, mppt_a_fond: actifs,
part_reseau_pct: importW !== null && conso ? Math.round((100 * importW) / conso) : null,
};
const verdict = (etat, explication) => ({ etat, explication, ...attributs });
if (pv === null || conso === null || reseau === null) {
return verdict('indetermine', 'Mesure PV, consommation ou reseau indisponible : ne pas conclure.');
}
if (limites === 0 && pv < PV_NUIT_W) {
return verdict('pas_de_production', 'Pas de production solaire (nuit ou aube) : rien a diagnostiquer.');
}
if (modes.length === 0) {
return verdict('indetermine', 'Aucun mode de chargeur lisible : impossible de distinguer bridage et baisse.');
}
if (limites > 0) {
if (importW > IMPORT_BRUIT_W) {
return verdict('substitution_edf',
`ANOMALIE : ${limites} chargeur(s) bride(s) alors que le reseau importe ${Math.round(importW)} W ` +
`— EDF fournit ce que le solaire pourrait donner. A signaler.`);
}
return verdict('bridage_normal',
`${limites} chargeur(s) se retiennent et le reseau n'importe rien : bridage normal d'une ` +
`installation sans injection. De la ressource solaire reste disponible pour une charge de plus.`);
}
if (importW > IMPORT_BRUIT_W) {
return verdict('reseau_en_appoint',
`Tous les chargeurs donnent leur maximum ; le reseau complete (${Math.round(importW)} W). ` +
`Le solaire n'est pas gache.`);
}
return verdict('ressource_limitee',
'Tous les chargeurs donnent leur maximum : la production est limitee par le soleil, rien n\'est gache.');
}
// ---------------------------------------------------------------------
// G13b — detail cellule : une valeur perimee ne doit plus etre VISIBLE
// ---------------------------------------------------------------------
// Le releve serie tourne toutes les 10 min (deploy/releve-cellules/compose.yaml,
// --suivi 600). Perime au-dela de 3 pas, comme la garde anti-blocage de
// pylontech-console.mjs (§75) : au-dela, le lecteur est considere fige.
// Perime => ni ecart ni module publies : l'agent ne peut pas citer ce qu'il ne
// voit pas.
export const RELEVE_PAS_MIN = 10;
export const RELEVE_PERIME_MIN = 3 * RELEVE_PAS_MIN;
export function etatCellules(r) {
if (!r || r.age_min === null || r.age_min === undefined) {
return { etat: 'muet', attributs: { statut: 'muet', explication: 'Aucun releve cellule en base.' } };
}
const age = Number(r.age_min);
if (age > RELEVE_PERIME_MIN) {
return {
etat: 'perime',
attributs: {
statut: 'perime', age_releve_min: age, seuil_perime_min: RELEVE_PERIME_MIN,
explication: `Releve cellule muet depuis ${age} min : aucune valeur actuelle a donner.`,
},
};
}
return {
etat: r.spread_max === null ? 'muet' : Number(r.spread_max),
attributs: {
statut: r.spread_max === null ? 'muet' : 'frais',
age_releve_min: age, seuil_perime_min: RELEVE_PERIME_MIN,
module_le_plus_sensible: r.weakest || null,
// Nombre de modules dans le releve : 9 attendus quand les deux packs sont
// lus (4 US5000 + 5 US2000C). Moins = un lecteur muet, l'ecart ne couvre
// alors qu'une partie du parc.
...(r.modules_lus !== undefined ? { modules_lus: r.modules_lus } : {}),
},
};
}
// ---------------------------------------------------------------------
// G1 + G9 — production PV reelle du jour (MANUEL §0)
// ---------------------------------------------------------------------
// Somme EXACTE des trois rendements journaliers de niveau appareil, jamais un
// sous-tracker du 450/200 (deja inclus dans son total : double comptage).
//
// ⚠️ Piege releve le 25/09 : les trois chargeurs ne se remettent PAS a zero au
// meme instant (00:37, 00:41 et 00:45 cette nuit-la). Entre minuit et leur remise
// a zero, un rendement non nul date d'HIER : sa derniere modification est
// anterieure a minuit. Il compte alors pour zero aujourd'hui (il n'y a de toute
// facon pas de soleil a cette heure), avec une mention, plutot que de gonfler le
// total du jour de celui de la veille.
export const RENDEMENTS_JOUR = [
['airtria_mppt1', 'sensor.airtria_mppt1_yield_today'],
['mppt_450_200', 'sensor.mppt_450_200_hq2449yr9pt_yield_today'],
['v1', 'sensor.v1_yield_today'],
];
function minuitLocal(maintenant) {
const jour = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Paris' }).format(maintenant);
// Heure de Paris a minuit exprimee en UTC : on cherche le decalage du jour.
const decalage = new Date(maintenant.toLocaleString('en-US', { timeZone: 'Europe/Paris' })) -
new Date(maintenant.toLocaleString('en-US', { timeZone: 'UTC' }));
return new Date(Date.parse(`${jour}T00:00:00Z`) - decalage);
}
export function productionJour(st, maintenant = new Date()) {
const minuit = minuitLocal(maintenant);
const detail = {};
const muets = [];
const pasRemisAZero = [];
let total = 0;
for (const [nom, id] of RENDEMENTS_JOUR) {
const s = st.get(id);
const v = s ? Number(s.state) : NaN;
if (!s || !Number.isFinite(v)) { muets.push(nom); detail[nom] = null; continue; }
const change = s.last_changed ? new Date(s.last_changed) : null;
if (v > 0 && change && change < minuit) { pasRemisAZero.push(nom); detail[nom] = 0; continue; }
detail[nom] = Math.round(v * 100) / 100;
total += v;
}
total = Math.round(total * 10) / 10;
if (muets.length === RENDEMENTS_JOUR.length) {
return { etat: 'indisponible', attributs: { statut: 'indisponible', chargeurs_muets: muets,
explication: 'Aucun des trois chargeurs ne publie son rendement : production du jour inconnue.' } };
}
const partiel = muets.length > 0;
return {
etat: total,
attributs: {
statut: partiel ? 'partiel' : 'complet',
chargeurs_muets: muets, detail_kwh: detail,
...(pasRemisAZero.length ? { pas_encore_remis_a_zero: pasRemisAZero } : {}),
explication: partiel
? `Production partielle : au moins ${total} kWh, ${muets.join(', ')} ne publie pas.`
: `Production reelle du jour : ${total} kWh (somme des trois chargeurs).`,
},
};
}
// ---------------------------------------------------------------------
// G6 + G7 — la voiture charge-t-elle sur le solaire ? RELAIS du controleur
// ---------------------------------------------------------------------
// ⚠️ Ce n'est PAS un second jugement. Le controleur EVCS (n8n, 30 s) decide seul,
// avec des regles que l'agent ne connait pas : excedent >= 0 ET un chargeur qui
// se retient pour la provocation (§37), plancher du soir tire du bilan (§11.8.E),
// tolerance batterie (§11.8.C), collant « vehicule plein ». Un agent qui
// recalculerait finirait par le contredire — les automates concurrents ont deja
// coute cher (§26, §27). On publie donc SA decision, et en attributs de quoi
// l'expliquer (excedent, chargeurs brides), sans decider.
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) {
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 puissance = nombre(st, 'sensor.victron_evcs_ac_power');
const source = txt('input_text.victron_evcs_source_mode');
const strategie = txt('input_select.victron_evcs_strategy');
const raisonLongue = txt('input_text.victron_evcs_surplus_reason');
const raison = raisonLongue ? raisonLongue.split(' | ')[0].trim() : null;
const pv = diagnosticPv(st);
const excedent = pv.pv_w !== null && pv.conso_w !== null ? Math.round(pv.pv_w - pv.conso_w) : null;
const attributs = {
source_controleur: source, strategie, etat_controleur: txt('input_text.victron_evcs_surplus_state'),
decision_controleur: txt('input_text.victron_evcs_surplus_current_decision'),
dernier_blocage: txt('input_text.victron_evcs_last_block_reason'), raison_controleur: raison,
puissance_borne_w: puissance, excedent_w: excedent, mppt_bridés: pv.mppt_bridés,
diagnostic_pv: pv.etat,
};
const r = (etat, explication) => ({ etat, attributs: { ...attributs, explication } });
if (branche === null || puissance === null || source === null) {
return r('indetermine', 'Etat de la borne ou du controleur illisible : ne pas conclure.');
}
if (branche !== 'on') {
return r('non_branchee', 'Aucun vehicule branche. Une fois branche, le controleur decidera lui-meme.');
}
if (puissance > SEUIL_CHARGE_W) {
const etat = ORIGINE_PAR_SOURCE[source] || 'en_charge';
return r(etat,
`La voiture charge (${Math.round(puissance)} W) ${PHRASE_ORIGINE[etat]} ` +
`(source decidee par le controleur : ${source}).`);
}
return r('en_attente',
`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,
},
};
}