Etude de la frontiere de l excedent : la voiture est un delestage, pas une charge

Integration de l historique HA sur 8 jours : 64% de l energie livree au
vehicule vient d un excedent reel, 16% sort de la reserve batterie. La
frontiere se ferme entre 18h30 et 19h50 fin aout, franchement.

Cote reserve nocturne, le bilan repond deja et il repond mal a notre
avantage : sept jours sur huit, meme pleine, la batterie ne passe pas la
nuit. Le deficit est structurel, ce qui est evitable c est de l aggraver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
Tallagrand Thierry
2026-09-01 00:49:13 +02:00
parent fc49e6c31a
commit 92f07fe885
2 changed files with 354 additions and 0 deletions

View File

@@ -0,0 +1,228 @@
#!/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)}%`
);
}