Capteurs production du jour et relais de charge pour l'agent vocal (G1/G9, G6/G7)

sensor.evcs_production_jour_kwh (somme des 3 yield_today, remises a zero
decalees gerees) et sensor.evcs_charge_solaire (relais de la decision du
controleur, pas un second arbitre). Tests 26/26 + mutations.

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 01:18:35 +02:00
parent 83c790d9b3
commit 30193a1d27
4 changed files with 202 additions and 4 deletions

View File

@@ -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}).`);
}

View File

@@ -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;
}