diff --git a/HANDOFF.md b/HANDOFF.md index dbcbcec..87b7e4f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -551,5 +551,22 @@ Les deux sont republies meme quand le cycle echoue. Les anciens helpers (package `evcs_cellules.yaml` supprime, sauvegarde `.bak-20260925` a cote). Banc SPARK apres migration : 13/13, G5 7/7, G13b 7/7 ; fiche 28 247 -> 27 129 car. +Suite du meme jour, deux capteurs de plus (tests 26/26, mutations qui mordent) : +- `sensor.evcs_production_jour_kwh` (G1/G9) : somme des trois `yield_today` + device, statut complet/partiel/indisponible, chargeurs muets nommes. ⚠️ Les + trois chargeurs se remettent a zero a des heures DIFFERENTES (00:37, 00:41, + 00:45 le 25/09) : un rendement non nul modifie avant minuit compte pour 0. +- `sensor.evcs_charge_solaire` (G6/G7) : RELAIS de la decision du controleur + (`non_branchee` / `en_attente` / `en_charge_solaire` / `en_charge_reseau` / + `indetermine`), raison du controleur et excedent en attributs. Delibrement PAS + un second arbitre : l'agent explique, il ne decide pas. +Defaut corrige dans la fiche par SPARK : « off » ne prouve pas une ressource +refusee (la nuit les 7 sont off) — seul `limited` compte. +Multi-agents (code du gateway lu par SPARK) : possible, routage par point +d'entree (binding canal/compte/fil), outils et memoire isoles par agent ; UN seul +point d'entree vocal => le vocal reste a l'agent par defaut (maison). Serveur +Spark `max-batch-size 1` : requetes serialisees, aucun gain de debit. Retenu : +pas d'agent d'accueil (double la latence, aiguillage faillible), un fil par agent. + --- -*Document mis a jour le : 25/09/2026 — diagnostics PV et cellule calcules cote service, publies pour l'agent vocal* +*Document mis a jour le : 25/09/2026 — quatre capteurs-verdict pour l'agent vocal, cadrage multi-agents* diff --git a/tests/diagnostics-agent/run.mjs b/tests/diagnostics-agent/run.mjs index c915f22..d104170 100644 --- a/tests/diagnostics-agent/run.mjs +++ b/tests/diagnostics-agent/run.mjs @@ -1,7 +1,7 @@ // 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. // node tests/diagnostics-agent/run.mjs -import { diagnosticPv, etatCellules, MPPT_MODES } from '../../tools/diagnostics-agent.mjs'; +import { diagnosticPv, etatCellules, productionJour, chargeSolaire, MPPT_MODES } from '../../tools/diagnostics-agent.mjs'; const LIM = 'voltage_current_limited', ACT = 'mppt_active'; function st({ pv, conso, reseau, soc = 80, courant = 0, edf = 'off', modes }) { @@ -65,5 +65,68 @@ for (const [nom, row, test] of cel) { if (!ok) ko++; console.log(`${ok ? 'OK ' : 'KO '} ${nom}`); } -console.log(`\n${cas.length + cel.length - ko}/${cas.length + cel.length}`); +// --- Production du jour (G1, G9) --------------------------------------- +const MIDI = new Date('2026-09-24T12:00:00Z'); // 14:00 a Paris +const NUIT = new Date('2026-09-24T22:40:00Z'); // 00:40 a Paris le 25 +function rend(v1, v2, v3, change = '2026-09-24T11:59:00Z') { + const m = new Map(); + [['sensor.airtria_mppt1_yield_today', v1], ['sensor.mppt_450_200_hq2449yr9pt_yield_today', v2], + ['sensor.v1_yield_today', v3]].forEach(([id, v]) => { + const [val, lc] = Array.isArray(v) ? v : [v, change]; + m.set(id, { state: val === null ? 'unavailable' : String(val), last_changed: lc }); + }); + // un sous-tracker present ne doit JAMAIS etre additionne + m.set('sensor.mppt_450_200_hq2449yr9pt_tracker_solar_1_yield_today', { state: '20.0', last_changed: change }); + return m; +} +const prod = [ + ['G1 somme des trois (45,1 du 24/09), sous-tracker ignore', rend(9.45, 32.71, 2.97), MIDI, + (r) => r.etat === 45.1 && r.attributs.statut === 'complet'], + ['G9 un chargeur unavailable -> partiel, somme du reste, muet nomme', rend(9.45, 32.71, null), MIDI, + (r) => r.etat === 42.2 && r.attributs.statut === 'partiel' && r.attributs.chargeurs_muets.join() === 'v1'], + ['tous muets -> indisponible', rend(null, null, null), MIDI, (r) => r.etat === 'indisponible'], + ['00:40 : un chargeur pas encore remis a zero (valeur d hier) -> compte 0', + rend(['0.0', '2026-09-24T22:37:00Z'], ['32.71', '2026-09-24T17:30:00Z'], ['0.0', '2026-09-24T22:37:00Z']), NUIT, + (r) => r.etat === 0 && r.attributs.pas_encore_remis_a_zero.join() === 'mppt_450_200'], + ['heure d ete : 23:30 Paris le 24 = encore aujourd hui', rend(9.45, 32.71, 2.97, '2026-09-24T17:30:00Z'), + new Date('2026-09-24T21:30:00Z'), (r) => r.etat === 45.1], +]; +for (const [nom, etats, t, test] of prod) { + const ok = test(productionJour(etats, t)); + if (!ok) { ko++; console.log(JSON.stringify(productionJour(etats, t))); } + console.log(`${ok ? 'OK ' : 'KO '} ${nom}`); +} + +// --- Charge voiture : RELAIS du controleur (G6, G7) -------------------- +function borne({ branche = 'on', p = 0, source = 'Solaire', raison = 'Excedent negatif | Profil=x', pv = 2000, conso = 3854, modes = ACT }) { + const m = st({ pv, conso, reseau: 0, modes }); + const put = (id, v) => m.set(id, { state: String(v) }); + put('binary_sensor.victron_evcs_vehicle_connected', branche); + put('sensor.victron_evcs_ac_power', p); + put('input_text.victron_evcs_source_mode', source); + put('input_text.victron_evcs_surplus_reason', raison); + put('input_select.victron_evcs_strategy', 'auto_solar_plus'); + return m; +} +const charge = [ + ['G6 branchee, pas en charge -> en_attente + raison du controleur, excedent explicatif', + borne({ source: 'Bloqué', raison: 'Excedent insuffisant | Profil=outside_window' }), + (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 source reseau -> en_charge_reseau', borne({ p: 3600, source: 'Réseau HC autorisé' }), (r) => r.etat === 'en_charge_reseau'], + ['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)', + borne({ pv: 5000, conso: 1500, modes: LIM, source: 'Bloqué', raison: 'Vehicule plein' }), + (r) => r.etat === 'en_attente' && r.attributs.diagnostic_pv === 'bridage_normal'], + ['borne illisible -> indetermine', (() => { const m = borne({}); m.set('sensor.victron_evcs_ac_power', { state: 'unavailable' }); return m; })(), + (r) => r.etat === 'indetermine'], +]; +for (const [nom, etats, test] of charge) { + const ok = test(chargeSolaire(etats)); + if (!ok) { ko++; console.log(JSON.stringify(chargeSolaire(etats))); } + console.log(`${ok ? 'OK ' : 'KO '} ${nom}`); +} + +const n = cas.length + cel.length + prod.length + charge.length; +console.log(`\n${n - ko}/${n}`); process.exit(ko ? 1 : 0); diff --git a/tools/diagnostics-agent.mjs b/tools/diagnostics-agent.mjs index 291f98a..323e793 100644 --- a/tools/diagnostics-agent.mjs +++ b/tools/diagnostics-agent.mjs @@ -135,3 +135,110 @@ export function etatCellules(r) { }, }; } + +// --------------------------------------------------------------------- +// 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}).`); +} diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 04679d3..84c29a6 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -30,7 +30,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import pg from 'pg'; import { texteRapport, envoyer } from './rapport-nuit.mjs'; -import { diagnosticPv, etatCellules } from './diagnostics-agent.mjs'; +import { diagnosticPv, etatCellules, productionJour, chargeSolaire } from './diagnostics-agent.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ONCE = process.argv.includes('--once'); @@ -1834,6 +1834,17 @@ async function publierDiagnosticPv(st) { friendly_name: 'EVCS — diagnostic production PV', icon: 'mdi:solar-power-variant', ...d, calcule_a: new Date().toISOString(), }); + // Production du jour (G1/G9) et relais de la decision de charge (G6/G7) : + // memes proprietes, ne dependent que de HA. + const prod = productionJour(st); + await publierCapteur('sensor.evcs_production_jour_kwh', prod.etat, { + friendly_name: 'EVCS — production PV reelle du jour', icon: 'mdi:solar-power', + unit_of_measurement: typeof prod.etat === 'number' ? 'kWh' : undefined, ...prod.attributs, + }); + const ch = chargeSolaire(st); + await publierCapteur('sensor.evcs_charge_solaire', ch.etat, { + friendly_name: 'EVCS — charge voiture (decision du controleur)', icon: 'mdi:ev-station', ...ch.attributs, + }); return 1; }