diff --git a/tests/diagnostics-agent/run.mjs b/tests/diagnostics-agent/run.mjs index 2a65118..8e64665 100644 --- a/tests/diagnostics-agent/run.mjs +++ b/tests/diagnostics-agent/run.mjs @@ -2,7 +2,7 @@ // Les cas PV reprennent les gammes du banc OpenClaw (HANDOFF §77) : G3, G4, G5, G7. // node tests/diagnostics-agent/run.mjs import fs from 'node:fs'; -import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, MPPT_MODES, ORIGINE_PAR_SOURCE } from '../../tools/diagnostics-agent.mjs'; +import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, COMPTEURS_IMPORT, COMPTEUR_EDF, MPPT_MODES, ORIGINE_PAR_SOURCE } 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 }) { @@ -223,6 +223,31 @@ for (const [nom, r, test] of res) { console.log(`${ok ? 'OK ' : 'KO '} ${nom}`); } -const n = cas.length + cel.length + prod.length + charge.length + 1 + bn.length + res.length; +// Import depuis minuit. Valeurs de minuit fictives, deltas du 26/09 (0,8 kWh VRM). +const [OUT, INV] = COMPTEURS_IMPORT; +const mn = { [OUT]: 1200.00, [INV]: 845.00, [COMPTEUR_EDF]: 3082.00 }; +const imp = [ + ['26/09 : 0,5 vers maison + 0,3 vers batterie -> 0,8 complet, recoupe EDF', + importJour({ [OUT]: 1200.5, [INV]: 845.3, [COMPTEUR_EDF]: 3082.83 }, mn), + (r) => r.etat === 0.8 && r.attributs.statut === 'complet' && r.attributs.compteur_edf_kwh === 0.8 + && r.attributs.detail_kwh.vers_ac_out === 0.5 && r.attributs.detail_kwh.vers_inverter === 0.3], + ['journee sans reseau -> 0, pas indisponible', + importJour({ [OUT]: 1200, [INV]: 845, [COMPTEUR_EDF]: 3082 }, mn), (r) => r.etat === 0 && r.attributs.statut === 'complet'], + ['compteur remis a zero -> indetermine, jamais un import negatif', + importJour({ [OUT]: 3.2, [INV]: 845.3 }, mn), (r) => r.etat === 'indetermine'], + ['un compteur illisible -> partiel, au moins', + importJour({ [OUT]: 1200.5, [INV]: null }, mn), (r) => r.etat === 0.5 && r.attributs.statut === 'partiel'], + ['valeur de minuit inconnue -> indisponible', + importJour({ [OUT]: 1200.5, [INV]: 845.3 }, {}), (r) => r.etat === 'indisponible'], + ['compteur EDF absent : le total ne change pas, recoupement null', + importJour({ [OUT]: 1200.5, [INV]: 845.3 }, mn), (r) => r.etat === 0.8 && r.attributs.compteur_edf_kwh === null], +]; +for (const [nom, r, test] of imp) { + 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 + res.length + imp.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 8100301..f620b88 100644 --- a/tools/diagnostics-agent.mjs +++ b/tools/diagnostics-agent.mjs @@ -160,7 +160,7 @@ export const RENDEMENTS_JOUR = [ ['v1', 'sensor.v1_yield_today'], ]; -function minuitLocal(maintenant) { +export 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' })) - @@ -410,3 +410,59 @@ export function etatReseau(st, maintenant = new Date()) { return verdict('raccorde', `Reseau EDF raccorde depuis ${attributs.depuis_heure ?? '?'}, import ${attributs.import_w} W.`); } + +// --------------------------------------------------------------------- +// Energie importee d'EDF depuis minuit — le chiffre « Depuis reseau » de VRM +// --------------------------------------------------------------------- +// Demande du proprietaire le 26/09 : l'agent vocal doit lui eviter d'ouvrir VRM. +// La question « combien ai-je importe » est une QUANTITE ; la puissance +// instantanee (victron_grid_power_l1) n'y repond pas, elle vaut 0 des que le +// contacteur est ouvert. +// Source : les compteurs cumules AC-in du MultiPlus, sains et monotones (HANDOFF +// §76 : 1,60 kWh la nuit du 24/09, recoupes par le compteur EDF a 1,53). Import +// = vers la maison (ac_out) + vers la batterie (inverter). Le compteur du +// contacteur EDF est publie a cote comme recoupement, pas additionne. +// ⚠️ Compteurs sans last_reset : une valeur courante INFERIEURE a celle de +// minuit est une remise a zero, pas un import negatif -> indetermine. +export const COMPTEURS_IMPORT = [ + 'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_ac_out', + 'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_inverter', +]; +export const COMPTEUR_EDF = 'sensor.edf_general_ok_energie_totale'; + +// courant, minuit : { [entity_id]: nombre | null } +export function importJour(courant, minuit) { + const detail = {}; + const muets = []; + let total = 0; + for (const id of COMPTEURS_IMPORT) { + const c = courant[id], m = minuit[id]; + const court = id.replace('sensor.airtria_mpii_v1_energy_from_ac_in_1_to_', 'vers_'); + if (!Number.isFinite(c) || !Number.isFinite(m)) { muets.push(court); detail[court] = null; continue; } + if (c < m) { + return { etat: 'indetermine', attributs: { statut: 'indetermine', + explication: `Compteur ${court} remis a zero depuis minuit : import du jour inconnu.` } }; + } + detail[court] = Math.round((c - m) * 100) / 100; + total += c - m; + } + if (muets.length === COMPTEURS_IMPORT.length) { + return { etat: 'indisponible', attributs: { statut: 'indisponible', + explication: 'Compteurs d\'energie AC-in illisibles : import du jour inconnu.' } }; + } + total = Math.round(total * 10) / 10; + const ce = courant[COMPTEUR_EDF], me = minuit[COMPTEUR_EDF]; + const edf = Number.isFinite(ce) && Number.isFinite(me) && ce >= me ? Math.round((ce - me) * 10) / 10 : null; + const partiel = muets.length > 0; + return { + etat: total, + attributs: { + statut: partiel ? 'partiel' : 'complet', + detail_kwh: detail, + compteur_edf_kwh: edf, + explication: partiel + ? `Au moins ${total} kWh importes d'EDF depuis minuit (compteur ${muets.join(', ')} muet).` + : `${total} kWh importes d'EDF depuis minuit.`, + }, + }; +} diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index c7e5a68..a6d51d0 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, donneesNuit, texteDepuis } from './rapport-nuit.mjs'; -import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau } from './diagnostics-agent.mjs'; +import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF } from './diagnostics-agent.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ONCE = process.argv.includes('--once'); @@ -83,6 +83,11 @@ const MESURES = [ 'input_number.evcs_balance_required_soc', 'input_number.evcs_balance_energy_to_cover_kwh', 'input_number.victron_evcs_night_target_soc', + // Compteurs d'energie cumules de l'import (26/09) : HANDOFF §76 notait leur + // absence, qui empechait tout calcul d'import par delta de compteur. + 'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_ac_out', + 'sensor.airtria_mpii_v1_energy_from_ac_in_1_to_inverter', + 'sensor.edf_general_ok_energie_totale', ]; // --------------------------------------------------------------------- @@ -1853,6 +1858,10 @@ async function publierDiagnosticPv(st) { await publierCapteur('sensor.evcs_charge_solaire', ch.etat, { friendly_name: 'EVCS — charge voiture (decision du controleur)', icon: 'mdi:ev-station', ...ch.attributs, }); + // Import EDF depuis minuit (« Depuis reseau » de VRM), sur les compteurs cumules. + await publierImportJour(st).catch((e) => { + console.error(`import du jour : ${e.message}`); return 0; + }); // Raccordement reseau : contacteur ouvert = import nul, pas une panne (26/09). const rr = etatReseau(st); await publierCapteur('sensor.evcs_reseau', rr.etat, { @@ -1861,6 +1870,51 @@ async function publierDiagnosticPv(st) { return 1; } +// Valeur des compteurs d'energie a minuit (heure de Paris), lue UNE fois par jour +// dans l'historique de HA : la derniere valeur connue avant minuit. L'historique +// renvoie en tete l'etat en vigueur au debut de la fenetre, donc un compteur +// immobile (contacteur ouvert toute la soiree) a bien une valeur. +const IDS_IMPORT = [...COMPTEURS_IMPORT, COMPTEUR_EDF]; +let minuitCache = { jour: null, valeurs: null }; +async function valeursAMinuit() { + const minuit = minuitLocal(new Date()); + const jour = minuit.toISOString(); + if (minuitCache.jour === jour) return minuitCache.valeurs; + const debut = new Date(minuit.getTime() - 6 * 3600e3).toISOString(); + const r = await fetch( + `${HA_URL}/api/history/period/${debut}?end_time=${encodeURIComponent(jour)}` + + `&filter_entity_id=${IDS_IMPORT.join(',')}&minimal_response&no_attributes`, + { headers: { Authorization: `Bearer ${HA_TOKEN}` }, signal: AbortSignal.timeout(15000) }, + ); + if (!r.ok) throw new Error(`historique HA ${r.status}`); + const series = await r.json(); + const valeurs = {}; + for (const serie of series) { + const id = serie[0]?.entity_id; + const nums = serie.map((e) => Number(e.state)).filter(Number.isFinite); + if (id) valeurs[id] = nums.length ? nums[nums.length - 1] : null; + } + // On ne met en cache qu'une lecture complete : sinon on reessaie au cycle suivant. + if (COMPTEURS_IMPORT.every((id) => Number.isFinite(valeurs[id]))) minuitCache = { jour, valeurs }; + return valeurs; +} + +async function publierImportJour(st) { + const minuit = await valeursAMinuit(); + const courant = {}; + for (const id of IDS_IMPORT) { + const v = Number(st.get(id)?.state); + courant[id] = Number.isFinite(v) ? v : null; + } + const ij = importJour(courant, minuit); + await publierCapteur('sensor.evcs_import_jour_kwh', ij.etat, { + friendly_name: 'EVCS — energie importee d\'EDF depuis minuit', icon: 'mdi:transmission-tower-import', + unit_of_measurement: typeof ij.etat === 'number' ? 'kWh' : undefined, ...ij.attributs, + calcule_a: new Date().toISOString(), + }); + 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