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:
19
HANDOFF.md
19
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*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}).`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user