#!/usr/bin/env node // Workflow « Water Heater Advisor ECS 6 Nodes » (07/10/2026) : la chaine // Normalize -> ... -> BUILD executee sur des snapshots, comme en production. // Usage : node tests/water-heater/run.mjs import fs from 'node:fs'; import path from 'node:path'; import vm from 'node:vm'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const CHAINE = ['NORMALIZE', 'OBSERVATION', 'ENERGY_MODEL', 'BASE_DECISION', 'ARBITRATION', 'OUTPUT_BUILDER', 'BUILD_ACTIONS']; const code = Object.fromEntries(CHAINE.map((n) => [n, fs.readFileSync(path.join(ROOT, `src/water-heater/${n}.js`), 'utf8')])); function executer(snapshot) { let item = snapshot, sorties = {}; for (const n of CHAINE) { const sb = { $input: { first: () => ({ json: item }), all: () => [{ json: item }] }, $env: {}, console }; vm.createContext(sb); const out = vm.runInContext(`(function(){ ${code[n]} })()`, sb, { timeout: 5000 }); sorties[n] = out; item = out[0].json; } return sorties; } // Snapshot de base : matin d'un jour faible, contacteur ouvert, chauffe-eau sous tension. const BASE = { soc_percent: '45', battery_current_a: '-5', pv_power_w: '900', pv_power_10m_avg_w: '850', house_ac_power_w: '2000', grid_power_w: '0', grid_state: 'off', victron_acin1_available: '0', today_remaining_kwh: '15', tomorrow_kwh: '25', forecast_source: 'vrm', advisor_enabled: 'on', notifications_enabled: 'off', hot_water_demand: 'normal', water_heater_switch: 'on', water_heater_energy_total_kwh: '18.25', ecs_nuit: 'on', hc_session_committed: 'off', ecs_24h_kwh: '5.5', ecs_jour_kwh: '3', off_jour_faible_prev: 'off', evcs_priority_active: 'off', forced_off_by_advisor: 'off', }; const cas = [ { id: 'jour-faible-coupe-le-jour', snap: {}, attendu: { action: 'turn_off', desired: false, etat: 'jour_faible_coupe', owner: true } }, { id: 'jour-faible-chauffe-en-hc-edf', snap: { water_heater_switch: 'off', off_jour_faible_prev: 'on', grid_state: 'on', hc_session_committed: 'on' }, attendu: { action: 'turn_on', desired: true, etat: 'jour_faible_chauffe_hc_edf', owner: true } }, { id: 'jour-faible-edf-ferme-hors-session-reste-coupe', snap: { water_heater_switch: 'off', off_jour_faible_prev: 'on', grid_state: 'on', hc_session_committed: 'off' }, attendu: { action: 'none', etat: 'jour_faible_coupe', owner: true } }, { id: 'jour-faible-eau-froide-chauffe', snap: { water_heater_switch: 'off', off_jour_faible_prev: 'on', ecs_24h_kwh: '0.4' }, attendu: { action: 'turn_on', desired: true, etat: 'jour_faible_eau_froide', owner: true } }, { id: 'jour-faible-energie-illisible-aucune-restriction', snap: { ecs_24h_kwh: 'indisponible' }, attendu: { action: 'none', etat: 'jour_faible_energie_illisible', owner: true } }, { id: 'jour-faible-surplus-solaire-chauffe', snap: { water_heater_switch: 'off', off_jour_faible_prev: 'on', pv_power_w: '3500', pv_power_10m_avg_w: '3400', house_ac_power_w: '900' }, attendu: { action: 'turn_on', desired: true, etat: 'jour_faible_surplus_solaire', owner: true } }, { id: 'jour-faible-surplus-insuffisant-reste-coupe', snap: { water_heater_switch: 'off', off_jour_faible_prev: 'on', pv_power_w: '1500', house_ac_power_w: '900' }, attendu: { action: 'none', etat: 'jour_faible_coupe', owner: true } }, { id: 'jour-faible-sous-tension-sur-batterie-coupe', snap: { water_heater_switch: 'on', pv_power_w: '1500', house_ac_power_w: '2100' }, attendu: { action: 'turn_off', desired: false, etat: 'jour_faible_coupe', owner: true } }, { id: 'jour-normal-remise-sous-tension', snap: { ecs_nuit: 'off', water_heater_switch: 'off', off_jour_faible_prev: 'on' }, attendu: { action: 'turn_on', desired: true, etat: 'jour_normal_remise_sous_tension', owner: false } }, { id: 'jour-normal-coupure-manuelle-respectee', snap: { ecs_nuit: 'off', water_heater_switch: 'off', off_jour_faible_prev: 'off' }, attendu: { action: 'none', owner: false } }, { id: 'conseiller-desactive-rien', snap: { advisor_enabled: 'off' }, attendu: { action: 'none', owner: false } }, ]; let ok = 0, ko = 0; for (const c of cas) { const s = executer({ ...BASE, ...c.snap }); const fd = s.ARBITRATION[0].json.finalDecision; const appels = s.BUILD_ACTIONS.map((x) => x.json); const sw = appels.find((a) => a.domain === 'switch' && a.data?.entity_id === 'switch.tt_chauffe_eau_pleumeur_switch'); const own = appels.find((a) => JSON.stringify(a).includes('victron_water_heater_off_jour_faible')); const erreurs = []; if (fd.powerSwitchAction !== c.attendu.action) erreurs.push(`action ${fd.powerSwitchAction} (attendu ${c.attendu.action})`); if (c.attendu.desired !== undefined && fd.powerSwitchDesiredOn !== c.attendu.desired) erreurs.push(`desired ${fd.powerSwitchDesiredOn}`); if (c.attendu.etat && fd.jourFaibleState !== c.attendu.etat) erreurs.push(`etat ${fd.jourFaibleState} (attendu ${c.attendu.etat})`); if (fd.offJourFaible !== c.attendu.owner) erreurs.push(`propriete ${fd.offJourFaible}`); if (c.attendu.action !== 'none' && !sw) erreurs.push('aucun appel switch emis par BUILD'); if (c.attendu.action === 'none' && sw) erreurs.push('appel switch emis alors qu aucune action'); if (!own) erreurs.push('helper de propriete absent des appels'); if (erreurs.length) { ko++; console.log(`❌ ${c.id} | ${erreurs.join(' ; ')}`); } else { ok++; console.log(`✅ ${c.id}`); } } // Coupe = 0 W meme si le compteur remonte en retard (07/10 : « arret — 1595 W »). { const stamp = new Date(Date.now() - 5 * 60000).toLocaleString('sv-SE', { timeZone: 'Europe/Paris' }); const s = executer({ ...BASE, water_heater_switch: 'off', water_heater_energy_total_kwh: '18.88', energy_total_prev_kwh: '18.75', energy_prev_at: stamp }); const o = s.OBSERVATION[0].json.observation; if (o.observedPowerW === 0 && o.observedRegime === 'off') { ok++; console.log('✅ coupe-zero-watt'); } else { ko++; console.log(`❌ coupe-zero-watt | ${o.observedPowerW} W ${o.observedRegime}`); } } // Alerte BOOST (07/10) : en HC sur EDF, la resistance n'a jamais ete atteinte. { const nowMin = (() => { const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date()).split(':'); return Number(p[0]) * 60 + Number(p[1]); })(); const hm = (m) => { const x = ((m % 1440) + 1440) % 1440; return `${String(Math.floor(x / 60)).padStart(2, '0')}:${String(x % 60).padStart(2, '0')}:00`; }; const stamp = new Date(Date.now() - 5 * 60000).toLocaleString('sv-SE', { timeZone: 'Europe/Paris' }); const fenetre = { hc_midday_start: hm(nowMin - 30), hc_midday_stop: hm(nowMin + 60), hc_night_start: hm(nowMin + 300), hc_night_stop: hm(nowMin + 600) }; const base = { ...BASE, ...fenetre, grid_state: 'on', hc_session_committed: 'on', water_heater_switch: 'on', water_heater_energy_total_kwh: '18.65', energy_total_prev_kwh: '18.60', energy_prev_at: stamp }; // 600 W : pompe a chaleur const debut = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }) + ' ' + hm(nowMin - 30).slice(0, 5); const verif = (nom, snap, attendu) => { const s = executer({ ...base, ...snap }); const a = s.ARBITRATION[0].json.finalDecision.alerteChauffeEau; let j = null; try { j = JSON.parse(a); } catch {} const actif = !!(j && j.active === true && j.message && j.message.includes('BOOST')); const appel = s.BUILD_ACTIONS.map((x) => x.json).find((c) => c.data?.entity_id === 'input_text.evcs_alerte_chauffe_eau'); if (actif === attendu && appel) { ok++; console.log(`✅ ${nom}`); } else { ko++; console.log(`❌ ${nom} | alerte=${a} appel=${!!appel}`); } }; verif('alerte-boost-pompe-a-chaleur-en-hc', {}, true); verif('alerte-boost-auto-arrete-0-w-en-hc', { water_heater_energy_total_kwh: '18.60' }, true); verif('alerte-boost-resistance-pas-d-alerte', { water_heater_energy_total_kwh: '18.75' }, false); verif('alerte-boost-resistance-deja-vue-dans-la-fenetre', { water_heater_energy_total_kwh: '18.60', alerte_prev: JSON.stringify({ active: false, since: debut, max_w: 1235 }) }, false); verif('alerte-boost-debut-de-fenetre-attente', { hc_midday_start: hm(nowMin - 5) }, false); verif('alerte-boost-edf-ouvert-pas-d-alerte', { grid_state: 'off' }, false); } // Prevision forte = le reste du jour seulement (06/10 : demain fort ne rend plus forte une journee faible). { const s = executer({ ...BASE, ecs_nuit: 'off', today_remaining_kwh: '8', tomorrow_kwh: '30' }); const f = s.ENERGY_MODEL[0].json.energyModel.forecastStrength; if (f === 'weak') { ok++; console.log('✅ prevision-forte-sur-le-jour-seulement'); } else { ko++; console.log(`❌ prevision-forte-sur-le-jour-seulement | ${f}`); } } console.log(ko ? `\n${ko} ECHEC(S) — NE PAS POUSSER EN PRODUCTION` : `\nWATER HEATER OK — ${ok}/${ok}`); process.exit(ko ? 1 : 0);