Plan du planificateur HC ; dialogue avec le conseiller dans HA ; arbitre encadre (question a choix fermes, liste blanche)

This commit is contained in:
Tallagrand Thierry
2026-10-08 07:26:14 +02:00
parent b25770736f
commit dea9c88530
16 changed files with 1114 additions and 3 deletions

60
tools/arbitrage.mjs Normal file
View File

@@ -0,0 +1,60 @@
// =====================================================================
// ARBITRE ENCADRE (08/10/2026, decision du proprietaire)
//
// Pour les cas qu'aucune regle n'a prevus : le CODE detecte en direct une
// situation incoherente et formule une question a CHOIX FERMES ; OpenClaw
// (concierge, SPARK) l'envoie par WhatsApp ; la reponse du proprietaire est
// appliquee par le service d'historisation, et SEULEMENT l'ordre attache au
// choix retenu, pris dans la liste blanche du guichet d'ordres (ORDRES).
// Le modele ne decide rien et n'invente aucun choix : il relaie.
//
// Chaque arbitrage est historise (table `arbitrage`) : un cas qui revient avec
// la meme reponse deviendra une regle du planificateur, apres rejeu
// (docs/PLAN_PLANIFICATEUR_HC.md, §7).
//
// `detecterSituation` est pure (tests : tests/arbitrage/run.mjs).
// =====================================================================
export const DUREE_MIN = 10; // la situation doit durer 10 min
const VOITURE_ABSENTE_OU_PLEINE = /not_connected|done|full|complete|charged/i;
/**
* @param {object} e etat lu en direct
* fenetre : 'nuit' | 'midi' | null (fenetre HC en cours)
* finFenetre : 'HH:MM' | null
* contacteurOn, voitureBranchee : booleens
* borneW : puissance de la borne (W)
* sourceMode, etat, raison : controleur de la borne
* vehicule : selecteur (VW, JEEP, ZERO)
* modeNuit : 'sans_edf' | 'complete'
* @returns {null | { cas, question, choix: [{ cle, libelle, ordre: null | { ordre, valeur } }] }}
*/
export function detecterSituation(e) {
// CAS 1 — voiture branchee, non pleine, bloquee, alors qu'EDF est deja ferme
// en heures creuses. Cause de la nuit du 07-08/10 (VW bloquee 01:24-05:14).
if (e.fenetre && e.contacteurOn && e.voitureBranchee && Number(e.borneW) < 100
&& /bloqu/i.test(e.sourceMode || '') && !VOITURE_ABSENTE_OU_PLEINE.test(e.etat || '')) {
const v = e.vehicule || 'la voiture';
const nuit = e.fenetre === 'nuit';
if (nuit && e.modeNuit === 'complete') return null; // deja autorisee : ce n'est pas un choix
return {
cas: 'voiture_bloquee_edf_ferme',
question: `${v} est branchee et ne charge pas, alors qu'EDF est ferme en heures creuses (jusqu'a ${e.finFenetre || 'la fin de la fenetre'}). `
+ `Raison du controleur : « ${String(e.raison || 'inconnue').split('|')[0].trim()} ». Que fais-je ?`,
choix: [
nuit
? { cle: '1', libelle: `Charger ${v} sur EDF jusqu'a ${e.finFenetre || '07:24'} (mode de nuit « complete »)`, ordre: { ordre: 'mode_nuit', valeur: 'complete' } }
: { cle: '1', libelle: `Charger ${v} sur EDF jusqu'a ${e.finFenetre || '14:24'} (« force_grid_now », expire en fin d'heures creuses)`, ordre: { ordre: 'strategie', valeur: 'force_grid_now' } },
{ cle: '2', libelle: 'Laisser en attente (rien ne change)', ordre: null },
],
};
}
return null;
}
// Reponse du proprietaire : "1", "2", « 1 », « choix 2 »… -> cle ou null.
export function lireChoix(texte, choix) {
const m = /\b([1-9])\b/.exec(String(texte || ''));
if (!m) return null;
return choix.find((c) => c.cle === m[1]) || null;
}

View File

@@ -32,6 +32,7 @@ import pg from 'pg';
import { creerSchemaKm, ingererReleves, ingererNiveauSaisi, ingererJeep } from './kilometrage.mjs';
import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs';
import { donneesRevue, constatsRevue } from './revue-jour.mjs';
import { detecterSituation, lireChoix, DUREE_MIN as ARBITRAGE_DUREE_MIN } from './arbitrage.mjs';
import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from './diagnostics-agent.mjs';
import { transitionsDansSerie } from './allers-retours.mjs';
import { energieSur24h, energieDepuis, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.mjs';
@@ -1950,6 +1951,8 @@ const ORDRES = {
strategie: { entite: 'input_select.victron_evcs_strategy', genre: 'option' },
vehicule: { entite: 'input_select.victron_evcs_vehicle', genre: 'option' },
plafond_edf: { entite: 'input_number.evcs_plafond_courant_edf_on', genre: 'nombre', min: 6, max: 28 },
// 08/10 (proprietaire) : l'arbitre peut proposer le mode de nuit.
mode_nuit: { entite: 'input_select.victron_evcs_night_charge_mode', genre: 'option' },
};
async function appliquerOrdreAirtria(sql, client, st) {
@@ -2060,6 +2063,119 @@ async function appliquerOrdreAirtria(sql, client, st) {
return 1;
}
// ---------------------------------------------------------------------
// ARBITRE ENCADRE (08/10/2026, tools/arbitrage.mjs)
// ---------------------------------------------------------------------
// Situation incoherente tenue ARBITRAGE_DUREE_MIN -> question a choix fermes
// publiee dans sensor.evcs_arbitrage (OpenClaw l'envoie par WhatsApp). La
// reponse arrive dans input_text.evcs_arbitrage_reponse (JSON {id, choix,
// source, confirmed_at}) ; on n'applique QUE l'ordre attache au choix, pris dans
// ORDRES (liste blanche), d'une source humaine, si
// input_boolean.evcs_arbitrage_enabled est ON. Tout est historise (table
// `arbitrage`). Le guichet general (airtria_ordres_enabled) n'est pas concerne.
let arbitrageCandidat = null; // { cas, depuis }
let arbitrageSchema = false;
async function arbitrer(sql, client, st) {
if (!client) return 0;
if (!arbitrageSchema) {
await client.query(`CREATE TABLE IF NOT EXISTS arbitrage (
id text PRIMARY KEY, cas text, question text, choix jsonb, depuis timestamptz, ouvert timestamptz DEFAULT now(),
ferme timestamptz, reponse_cle text, reponse_source text, reponse_at timestamptz, ordre jsonb,
application text, issue text)`);
arbitrageSchema = true;
}
const maintenant = Date.now();
const hm = (id) => txt(st.get(id)?.state).slice(0, 5);
const minParis = (() => {
const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date(maintenant));
return Number(p.find((x) => x.type === 'hour').value) % 24 * 60 + Number(p.find((x) => x.type === 'minute').value);
})();
const dans = (a, b) => { const m = (x) => { const [h, mi] = x.split(':').map(Number); return h * 60 + mi; };
if (!a || !b) return false; const A = m(a), B = m(b); return A <= B ? minParis >= A && minParis < B : minParis >= A || minParis < B; };
const nuit = [hm('input_datetime.victron_evcs_hc_night_start'), hm('input_datetime.victron_evcs_hc_night_stop')];
const midi = [hm('input_datetime.victron_evcs_hc_midday_start'), hm('input_datetime.victron_evcs_hc_midday_stop')];
const fenetre = dans(...nuit) ? 'nuit' : dans(...midi) ? 'midi' : null;
const sit = detecterSituation({
fenetre, finFenetre: fenetre === 'nuit' ? nuit[1] : fenetre === 'midi' ? midi[1] : null,
contacteurOn: txt(st.get('switch.edf_general_ok_switch')?.state) === 'on',
voitureBranchee: txt(st.get('binary_sensor.victron_evcs_vehicle_connected')?.state) === 'on',
borneW: Number(st.get('sensor.victron_evcs_ac_power')?.state),
sourceMode: txt(st.get('input_text.victron_evcs_source_mode')?.state),
etat: txt(st.get('input_text.victron_evcs_surplus_state')?.state),
raison: txt(st.get('input_text.victron_evcs_surplus_reason')?.state),
vehicule: txt(st.get('input_select.victron_evcs_vehicle')?.state),
modeNuit: txt(st.get('input_select.victron_evcs_night_charge_mode')?.state),
});
const { rows: [ouvert] } = await client.query(`SELECT * FROM arbitrage WHERE ferme IS NULL ORDER BY ouvert DESC LIMIT 1`);
const publier = (etat, a) => publierCapteur('sensor.evcs_arbitrage', etat, { friendly_name: 'Arbitrage demande au proprietaire', ...a });
let n = 0;
// Fermeture : la situation a disparu (resolue par le systeme ou par la reponse).
if (ouvert && (!sit || sit.cas !== ouvert.cas)) {
const issue = ouvert.reponse_cle ? 'resolu_apres_reponse' : 'resolu_sans_reponse';
await client.query(`UPDATE arbitrage SET ferme = now(), issue = $2 WHERE id = $1`, [ouvert.id, issue]);
await journaliser(sql, 'arbitrage', `Arbitrage clos (${issue}) : ${ouvert.cas}`, { id: ouvert.id, issue });
await publier('aucun', { dernier_id: ouvert.id, dernier_cas: ouvert.cas, derniere_issue: issue });
arbitrageCandidat = null;
return 1;
}
if (!sit) {
arbitrageCandidat = null;
if (txt(st.get('sensor.evcs_arbitrage')?.state) === '') await publier('aucun', {});
return 0;
}
if (!ouvert) {
if (!arbitrageCandidat || arbitrageCandidat.cas !== sit.cas) { arbitrageCandidat = { cas: sit.cas, depuis: maintenant }; return 0; }
if (maintenant - arbitrageCandidat.depuis < ARBITRAGE_DUREE_MIN * 60000) return 0;
const id = `${sit.cas}-${new Date(arbitrageCandidat.depuis).toISOString().slice(0, 16)}`;
await client.query(`INSERT INTO arbitrage (id, cas, question, choix, depuis) VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`,
[id, sit.cas, sit.question, JSON.stringify(sit.choix), new Date(arbitrageCandidat.depuis)]);
await journaliser(sql, 'arbitrage', `Arbitrage demande : ${sit.cas}`, { id, question: sit.question });
await publier('question', { id, cas: sit.cas, question: sit.question,
choix: sit.choix.map((c) => ({ cle: c.cle, libelle: c.libelle })),
depuis: new Date(arbitrageCandidat.depuis).toISOString(),
consigne: 'Repondre par le numero du choix. Seul l ordre attache au choix est applique.' });
return 1;
}
// Reponse du proprietaire, par le concierge.
if (!ouvert.reponse_cle) {
let rep = null;
try { rep = JSON.parse(txt(st.get('input_text.evcs_arbitrage_reponse')?.state) || 'null'); } catch { rep = null; }
if (rep && rep.id === ouvert.id) {
const choix = lireChoix(rep.choix, ouvert.choix || []);
const source = txt(rep.source);
let application;
if (!choix) application = `refuse : choix « ${txt(rep.choix)} » inconnu`;
else if (!CANAUX_HUMAINS.has(source)) application = `refuse : source « ${source || 'absente'} » non humaine`;
else if (!choix.ordre) application = 'aucun ordre (laisser en attente)';
else if (txt(st.get('input_boolean.evcs_arbitrage_enabled')?.state) !== 'on') application = 'non applique : input_boolean.evcs_arbitrage_enabled est OFF';
else {
const spec = ORDRES[choix.ordre.ordre];
const cible = spec && st.get(spec.entite);
const valeur = txt(choix.ordre.valeur);
if (!spec || !cible) application = `refuse : ordre ${choix.ordre.ordre} hors liste blanche`;
else if (spec.genre !== 'option' || !(cible.attributes?.options || []).includes(valeur)) application = `refuse : valeur ${valeur} invalide`;
else if (DRY) application = `essai a blanc : ${spec.entite} = ${valeur}`;
else {
const r = await fetch(`${HA_URL}/api/services/input_select/select_option`, { method: 'POST',
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_id: spec.entite, option: valeur }), signal: AbortSignal.timeout(8000) });
application = r.ok ? `applique : ${spec.entite} = ${valeur} (etait ${txt(cible.state)})` : `echec HTTP ${r.status}`;
}
}
await client.query(`UPDATE arbitrage SET reponse_cle = $2, reponse_source = $3, reponse_at = now(), ordre = $4, application = $5 WHERE id = $1`,
[ouvert.id, choix ? choix.cle : txt(rep.choix), source, JSON.stringify(choix?.ordre || null), application]);
await journaliser(sql, 'arbitrage', `Arbitrage repondu (${choix ? choix.cle : '?'}) : ${application}`, { id: ouvert.id, reponse: rep, application });
await publier('repondu', { id: ouvert.id, cas: ouvert.cas, question: ouvert.question,
choix: (ouvert.choix || []).map((c) => ({ cle: c.cle, libelle: c.libelle })),
reponse: choix ? choix.libelle : txt(rep.choix), application });
n++;
}
}
return n;
}
// ---------------------------------------------------------------------
// ANOMALIE "branchee mais ne charge pas" (19/09/2026)
// ---------------------------------------------------------------------
@@ -2516,6 +2632,9 @@ async function cycle(client) {
rapport: DRY ? 0 : await envoyerRapportDuMatin(sql, client).catch((e) => {
console.error('rapport de nuit :', e.message); return 0;
}),
arbitrage: DRY ? 0 : await arbitrer(sql, client, st).catch((e) => {
console.error('arbitrage :', e.message); return 0;
}),
revue: DRY ? 0 : await revueDeLaJournee(sql, client, st).catch((e) => {
console.error('revue de la journee :', e.message); return 0;
}),

View File

@@ -28,6 +28,18 @@ export const WORKFLOWS = [
{ file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Advisor', kind: 'template' },
],
},
{
// Dialogue avec le conseiller dans Home Assistant (08/10/2026).
id: 'conseiller-dialogue',
workflowFile: 'workflows/evcs-conseiller-dialogue-v1.json',
srcDir: 'src/conseiller-dialogue',
nodes: [
{ file: 'BUILD_PROMPT.js', node: 'BUILD_PROMPT', kind: 'jsCode' },
{ file: 'VALIDATE_PARSE.js', node: 'VALIDATE_PARSE', kind: 'jsCode' },
{ file: 'BUILD_HA_CALLS.js', node: 'BUILD_HA_CALLS', kind: 'jsCode' },
{ file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Dialogue', kind: 'template' },
],
},
{
id: 'energy-balance',
workflowFile: 'workflows/evcs-energy-balance-v1.json',

View File

@@ -108,6 +108,7 @@ console.log('Syntaxe OK sur tous les nodes jsCode.');
// 3bis. Suites de tests (R3) : bloquantes par workflow, sauf --skip-golden
const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json';
const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json';
const isDialogueWorkflow = path.basename(INPUT) === 'evcs-conseiller-dialogue-v1.json';
const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json';
const isAcmWorkflow = path.basename(INPUT) === 'victron-auto-charge-manager-v1.2.json';
const isWaterHeaterWorkflow = path.basename(INPUT) === 'water-heater-ecs-v1.json';
@@ -118,6 +119,7 @@ const skipGolden = process.argv.includes('--skip-golden');
const testSuite =
isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' :
isAdvisorWorkflow && fs.existsSync('tests/advisor/run.mjs') ? 'tests/advisor/run.mjs' :
isDialogueWorkflow && fs.existsSync('tests/conseiller-dialogue/run.mjs') ? 'tests/conseiller-dialogue/run.mjs' :
isHysteresisWorkflow && fs.existsSync('tests/golden-hysteresis/run.mjs') ? 'tests/golden-hysteresis/run.mjs' :
isAcmWorkflow && fs.existsSync('tests/golden-acm/run.mjs') ? 'tests/golden-acm/run.mjs' :
isBalanceWorkflow && fs.existsSync('tests/energy-balance/run.mjs') ? 'tests/energy-balance/run.mjs' :