// ===================================================================== // 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, }, }; } // --------------------------------------------------------------------- // 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 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 reseau = /^R[ée]seau/.test(source); return r(reseau ? 'en_charge_reseau' : 'en_charge_solaire', `La voiture charge (${Math.round(puissance)} W), 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}).`); }