#!/usr/bin/env node // Étude de la frontière « excédent » pour la charge du véhicule. // // Question posée par le propriétaire (31/08/2026) : la voiture doit être un // délestage de l'excédent PV, pas une charge prioritaire qui vide les // batteries. La frontière se détecte à la PUISSANCE DE DÉCHARGE BATTERIE, // pas à la production PV ni à un seuil de SOC. // // Ce script ne décide rien et n'écrit rien. Il intègre l'historique HA // (résolution native ~2 s) et répartit l'énergie réellement livrée au // véhicule entre trois origines physiques, instant par instant : // // batterie : min(P_evcs, décharge batterie) -> réserve entamée // réseau : min(reste, import réseau) -> énergie payante // excédent : le solde -> production qui aurait // été refusée ou stockée // // Usage: node tools/etude-frontiere-excedent.mjs [debut] [fin] // node tools/etude-frontiere-excedent.mjs 2026-08-24 2026-09-01 import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123'; function loadToken() { if (process.env.HA_TOKEN) return process.env.HA_TOKEN; const envPath = path.join(ROOT, '.env'); for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) { const m = line.match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/); if (m) return m[1].replace(/^["']|["']$/g, ''); } console.error('HA_TOKEN absent de .env'); process.exit(1); } const TOKEN = loadToken(); const ENT = { evcs: 'sensor.victron_evcs_ac_power', batt: 'sensor.victron_battery_power', pv: 'sensor.victron_pv_power_system', conso:'sensor.victron_consumption_power_l1', grid: 'sensor.victron_grid_power_l1', soc: 'sensor.victron_battery_soc', }; const KEYS = Object.keys(ENT); async function fetchDay(dayStart, dayEnd) { const url = `${HA_URL}/api/history/period/${dayStart.toISOString()}` + `?end_time=${encodeURIComponent(dayEnd.toISOString())}` + `&filter_entity_id=${encodeURIComponent(Object.values(ENT).join(','))}` + `&minimal_response`; const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } }); if (!res.ok) throw new Error(`HTTP ${res.status} sur ${dayStart.toISOString()}`); return res.json(); } // Fusionne les séries en un flux d'événements trié. function toEvents(series) { const byEntity = new Map(); for (const rows of series) { if (!rows.length) continue; byEntity.set(rows[0].entity_id, rows); } const ev = []; for (const [key, id] of Object.entries(ENT)) { for (const r of byEntity.get(id) || []) { const v = Number(r.state); if (!Number.isFinite(v)) continue; ev.push({ t: Date.parse(r.last_changed || r.last_updated), key, v }); } } ev.sort((a, b) => a.t - b.t); return ev; } const MAX_GAP_MS = 300e3; // au-delà, on considère une lacune et on n'intègre pas const HOURS = 24; function blankDay() { return { evcsKwh: 0, fromBattKwh: 0, fromGridKwh: 0, fromSurplusKwh: 0, pvKwh: 0, consoKwh: 0, gridImportKwh: 0, battDischargeKwh: 0, gapSec: 0, hour: Array.from({ length: HOURS }, () => ({ evcsKwh: 0, fromBattKwh: 0, fromGridKwh: 0, fromSurplusKwh: 0, })), socMin: null, socMax: null, lastSurplusMs: null, // dernier instant ou la voiture chargeait SANS decharger la batterie firstSurplusMs: null, }; } const args = process.argv.slice(2); const endArg = args[1] ? new Date(args[1]) : new Date(); const startArg = args[0] ? new Date(args[0]) : new Date(endArg.getTime() - 8 * 864e5); const days = new Map(); // 'YYYY-MM-DD' (Paris) -> agrégats const parisParts = (ms) => { const d = new Date(ms); const f = new Intl.DateTimeFormat('fr-CA', { timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false, }).formatToParts(d); const g = (t) => f.find((p) => p.type === t).value; return { day: `${g('year')}-${g('month')}-${g('day')}`, hour: Number(g('hour')) % 24 }; }; let cursor = new Date(startArg); const cur = {}; // dernières valeurs connues let lastT = null; while (cursor < endArg) { const chunkEnd = new Date(Math.min(cursor.getTime() + 864e5, endArg.getTime())); process.stderr.write(` lecture ${cursor.toISOString().slice(0, 10)}...\n`); const ev = toEvents(await fetchDay(cursor, chunkEnd)); for (const e of ev) { if (lastT !== null) { const dtMs = e.t - lastT; if (dtMs > 0) { const { day, hour } = parisParts(lastT); if (!days.has(day)) days.set(day, blankDay()); const D = days.get(day); if (dtMs > MAX_GAP_MS) { D.gapSec += dtMs / 1000; } else if (KEYS.every((k) => cur[k] !== undefined)) { const h = dtMs / 3.6e6; // heures const evcs = Math.max(0, cur.evcs); const battDis = Math.max(0, -cur.batt); // W déchargés const gridImp = Math.max(0, cur.grid); // W importés const fromBatt = Math.min(evcs, battDis); const fromGrid = Math.min(evcs - fromBatt, gridImp); const fromSurp = Math.max(0, evcs - fromBatt - fromGrid); D.evcsKwh += evcs * h / 1000; D.fromBattKwh += fromBatt * h / 1000; D.fromGridKwh += fromGrid * h / 1000; D.fromSurplusKwh += fromSurp * h / 1000; D.pvKwh += Math.max(0, cur.pv) * h / 1000; D.consoKwh += Math.max(0, cur.conso) * h / 1000; D.gridImportKwh += gridImp * h / 1000; D.battDischargeKwh += battDis * h / 1000; if (evcs > 200 && fromSurp > 0.6 * evcs) { if (D.firstSurplusMs === null) D.firstSurplusMs = lastT; D.lastSurplusMs = lastT; } const H = D.hour[hour]; H.evcsKwh += evcs * h / 1000; H.fromBattKwh += fromBatt * h / 1000; H.fromGridKwh += fromGrid * h / 1000; H.fromSurplusKwh += fromSurp * h / 1000; } if (cur.soc !== undefined) { D.socMin = D.socMin === null ? cur.soc : Math.min(D.socMin, cur.soc); D.socMax = D.socMax === null ? cur.soc : Math.max(D.socMax, cur.soc); } } } cur[e.key] = e.v; lastT = e.t; } cursor = chunkEnd; } const r1 = (x) => x.toFixed(1); const r2 = (x) => x.toFixed(2); console.log('\n=== ORIGINE DE L\'ENERGIE LIVREE AU VEHICULE (kWh/jour) ===\n'); console.log('jour EVCS excedent batterie reseau | PV conso import SOC'); let tot = { e: 0, s: 0, b: 0, g: 0 }; const sorted = [...days.keys()].sort(); for (const d of sorted) { const D = days.get(d); if (D.gapSec > 3600) console.log(` (lacune historique ${Math.round(D.gapSec / 60)} min le ${d})`); tot.e += D.evcsKwh; tot.s += D.fromSurplusKwh; tot.b += D.fromBattKwh; tot.g += D.fromGridKwh; console.log( `${d} ${r2(D.evcsKwh).padStart(6)} ${r2(D.fromSurplusKwh).padStart(7)} ` + `${r2(D.fromBattKwh).padStart(7)} ${r2(D.fromGridKwh).padStart(7)} | ` + `${r1(D.pvKwh).padStart(5)} ${r1(D.consoKwh).padStart(6)} ${r1(D.gridImportKwh).padStart(6)} ` + `${D.socMin ?? '?'}-${D.socMax ?? '?'}` ); } console.log(`\nTOTAL ${r2(tot.e).padStart(6)} ${r2(tot.s).padStart(7)} ${r2(tot.b).padStart(7)} ${r2(tot.g).padStart(7)}`); if (tot.e > 0) { console.log(`part 100% ${(100 * tot.s / tot.e).toFixed(0).padStart(6)}% ` + `${(100 * tot.b / tot.e).toFixed(0).padStart(6)}% ${(100 * tot.g / tot.e).toFixed(0).padStart(6)}%`); } const hhmm = (ms) => ms === null ? ' -- ' : new Date(ms).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }); console.log('\n=== FRONTIERE DE L\'EXCEDENT, JOUR PAR JOUR ===\n'); console.log('jour ouverture fermeture SOC max part excedent 16h 17h 18h 19h 20h'); for (const d of sorted) { const D = days.get(d); const sh = (h) => { const H = D.hour[h]; return H.evcsKwh < 0.05 ? ' . ' : `${(100 * H.fromSurplusKwh / H.evcsKwh).toFixed(0).padStart(3)}%`; }; console.log( `${d} ${hhmm(D.firstSurplusMs)} ${hhmm(D.lastSurplusMs)} ` + `${String(D.socMax ?? '?').padStart(3)}% ` + `${sh(16)} ${sh(17)} ${sh(18)} ${sh(19)} ${sh(20)}` ); } console.log('\n=== PAR HEURE DE LA JOURNEE, cumul sur la periode (kWh) ===\n'); console.log('h EVCS excedent batterie reseau part excedent'); for (let h = 0; h < HOURS; h++) { let e = 0, s = 0, b = 0, g = 0; for (const d of sorted) { const H = days.get(d).hour[h]; e += H.evcsKwh; s += H.fromSurplusKwh; b += H.fromBattKwh; g += H.fromGridKwh; } if (e < 0.01) continue; console.log( `${String(h).padStart(2)}h ${r2(e).padStart(7)} ${r2(s).padStart(7)} ${r2(b).padStart(7)} ` + `${r2(g).padStart(7)} ${(100 * s / e).toFixed(0).padStart(3)}%` ); }