Le reseau ne prend plus la place du solaire, batterie preneuse
Nuit complete + deux campagnes alternees apres la correction de la consigne reseau (4200 -> 50 W) : l'import colle a la consommation, la production monte librement contacteur ferme, et les transitions locales donnent +0,5 % et +13,1 % la ou le 27/08 mesurait -73 %. Le transitoire de fermeture est caracterise : le reseau reprend toute la maison et le solaire bascule vers la batterie, puis l'import retombe a la consigne en ~2 min 15 (25 W, 52 W). Cout ~50-60 Wh par fermeture. Outillage : tools/test-edf-alterne.mjs (protocole alterne, restauration garantie) et tools/analyse-test-alterne.mjs. Deux pieges d'instrumentation corriges -- garde meteo quand les phases ouvertes divergent entre elles, et biais du parseur qui laissait tomber les lignes ou le premier tracker etait bride. Reste ouvert : le cas batterie pleine, celui que le proprietaire designe comme decisif, ou le solaire n'a plus de destination quand le reseau reprend la maison. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
119
tools/analyse-test-alterne.mjs
Normal file
119
tools/analyse-test-alterne.mjs
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
// Resume par phase d'un journal produit par tools/test-edf-alterne.mjs.
|
||||
// Usage: node tools/analyse-test-alterne.mjs docs/mesures/<fichier>.log [secondesIgnorees]
|
||||
//
|
||||
// Les N premieres secondes de chaque phase sont ignorees par defaut (90 s) :
|
||||
// la bascule du contacteur met ~30 a 90 s a s'etablir, et inclure le transitoire
|
||||
// ecraserait l'ecart qu'on cherche a mesurer.
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
const file = process.argv[2];
|
||||
const SKIP = Number(process.argv[3] ?? 90);
|
||||
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
||||
|
||||
const rows = [];
|
||||
for (const l of lines) {
|
||||
const m = l.match(
|
||||
// Le bloc des modes MPPT est le DERNIER champ : on l'ancre sur "edf on|off",
|
||||
// jamais sur le mot MPPT. Ancrer sur "MPPT " faisait tomber en silence toute
|
||||
// ligne dont le PREMIER tracker etait bride -- c'est-a-dire precisement les
|
||||
// echantillons a mesurer -- et biaisait le resultat vers "aucun bridage".
|
||||
/^(\d{2}:\d{2}:\d{2}) \| (\w+)\s*\| PV\s+(-?\d+) \| U\s+([\d.]+) \| I\s+(-?[\d.]+) \| conso\s+(-?\d+) \| grid\s+(-?\d+) \| SOC\s+(\d+).*\| edf (?:on|off) \| (.*)$/
|
||||
);
|
||||
if (!m) continue;
|
||||
const [, t, phase, pv, u, i, conso, grid, soc, modes] = m;
|
||||
rows.push({
|
||||
t, phase,
|
||||
pv: +pv, u: +u, i: +i, conso: +conso, grid: +grid, soc: +soc,
|
||||
lim: modes.trim().split(/\s+/).filter((m) => m !== 'MPPT').length,
|
||||
sec: (+t.slice(0, 2)) * 3600 + (+t.slice(3, 5)) * 60 + (+t.slice(6, 8)),
|
||||
});
|
||||
}
|
||||
|
||||
const order = [];
|
||||
const byPhase = new Map();
|
||||
for (const r of rows) {
|
||||
if (!byPhase.has(r.phase)) { byPhase.set(r.phase, []); order.push(r.phase); }
|
||||
byPhase.get(r.phase).push(r);
|
||||
}
|
||||
|
||||
const avg = (a, k) => (a.length ? a.reduce((s, x) => s + x[k], 0) / a.length : NaN);
|
||||
const n = (x, d = 0) => (Number.isFinite(x) ? x.toFixed(d) : '—');
|
||||
|
||||
console.log(`Journal : ${file} (${rows.length} echantillons, ${SKIP}s de transitoire ignorees par phase)\n`);
|
||||
console.log('phase n | PV moy | PV max | conso | grid | I bat | SOC | trackers brides');
|
||||
console.log('---------------------------------------------------------------------------------');
|
||||
const summary = [];
|
||||
for (const p of order) {
|
||||
const all = byPhase.get(p);
|
||||
const t0 = all[0].sec;
|
||||
const a = all.filter((r) => r.sec - t0 >= SKIP);
|
||||
const use = a.length ? a : all;
|
||||
const s = {
|
||||
phase: p, n: use.length,
|
||||
pv: avg(use, 'pv'), pvmax: Math.max(...use.map((r) => r.pv)),
|
||||
conso: avg(use, 'conso'), grid: avg(use, 'grid'), i: avg(use, 'i'),
|
||||
soc: avg(use, 'soc'), lim: avg(use, 'lim'),
|
||||
};
|
||||
summary.push(s);
|
||||
console.log(
|
||||
`${p.padEnd(8)}${String(s.n).padStart(3)} |${n(s.pv).padStart(9)} |${n(s.pvmax).padStart(8)} |` +
|
||||
`${n(s.conso).padStart(8)} |${n(s.grid).padStart(7)} |${n(s.i, 1).padStart(7)} |${n(s.soc, 1).padStart(5)} |` +
|
||||
`${n(s.lim, 1).padStart(9)} / 7`
|
||||
);
|
||||
}
|
||||
|
||||
const off = summary.filter((s) => !s.phase.startsWith('ON'));
|
||||
const on = summary.filter((s) => s.phase.startsWith('ON'));
|
||||
|
||||
// --- garde meteo -------------------------------------------------------------
|
||||
// Les phases OUVERTES sont toutes censees voir la meme ressource solaire. Si
|
||||
// elles ne s'accordent pas entre elles, c'est le ciel qui a change pendant la
|
||||
// campagne : la moyenne ON contre moyenne OFF devient ininterpretable, et il ne
|
||||
// faut surtout pas la presenter comme un resultat (piege rencontre le 27/08 au
|
||||
// premier test, et de nouveau le 31/08).
|
||||
const pvOffs = off.map((s) => s.pv);
|
||||
const spread = Math.max(...pvOffs) - Math.min(...pvOffs);
|
||||
const spreadPct = (spread / (pvOffs.reduce((a, b) => a + b, 0) / pvOffs.length)) * 100;
|
||||
|
||||
console.log('\n--- STABILITE DE LA RESSOURCE ---');
|
||||
console.log(`PV moyenne des phases OUVERTES : ${off.map((s) => `${s.phase} ${n(s.pv)} W`).join(' | ')}`);
|
||||
console.log(`Ecart entre phases ouvertes : ${n(spread)} W (${n(spreadPct, 1)} % de la moyenne)`);
|
||||
const meteoStable = spreadPct < 15;
|
||||
console.log(meteoStable
|
||||
? '=> ressource stable : la comparaison globale ON/OFF est exploitable.'
|
||||
: '=> RESSOURCE INSTABLE (nuages) : la comparaison globale ON/OFF n est PAS exploitable.\n Se fier uniquement aux transitions ci-dessous, phase par phase.');
|
||||
|
||||
// --- comparaison locale : chaque ON contre ses voisines OFF -------------------
|
||||
console.log('\n--- TRANSITIONS (chaque phase fermee contre ses voisines ouvertes) ---');
|
||||
for (const s of on) {
|
||||
const idx = order.indexOf(s.phase);
|
||||
const voisins = summary.filter((x) => Math.abs(order.indexOf(x.phase) - idx) === 1 && !x.phase.startsWith('ON'));
|
||||
if (!voisins.length) continue;
|
||||
const ref = voisins.reduce((a, x) => a + x.pv, 0) / voisins.length;
|
||||
const d = s.pv - ref;
|
||||
const pct = (d / ref) * 100;
|
||||
console.log(
|
||||
`${s.phase} : PV ${n(s.pv)} W contre ${n(ref)} W chez ${voisins.map((v) => v.phase).join('+')}` +
|
||||
` => ${d >= 0 ? '+' : ''}${n(d)} W (${pct >= 0 ? '+' : ''}${n(pct, 1)} %)` +
|
||||
` | import ${n(s.grid)} W | trackers brides ${n(s.lim, 1)}/7`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n--- VERDICT ---');
|
||||
if (!meteoStable) {
|
||||
console.log('Campagne partiellement inexploitable (ressource instable).');
|
||||
console.log('Verifier a la main, sur le journal brut, si la chute de PV precede ou suit');
|
||||
console.log('l apparition de l import reseau : si elle la PRECEDE, ce sont des nuages.');
|
||||
} else {
|
||||
const pvOff = pvOffs.reduce((a, b) => a + b, 0) / pvOffs.length;
|
||||
const pvOn = on.reduce((a, s) => a + s.pv, 0) / on.length;
|
||||
const gOn = on.reduce((a, s) => a + s.grid, 0) / on.length;
|
||||
const pct = ((pvOn - pvOff) / pvOff) * 100;
|
||||
console.log(`PV ouverte ${n(pvOff)} W | PV fermee ${n(pvOn)} W | ecart ${pct >= 0 ? '+' : ''}${n(pct, 1)} %`);
|
||||
console.log(Math.abs(pct) < 10
|
||||
? '=> PAS DE SUBSTITUTION : la production ne depend pas de la presence du reseau.'
|
||||
: '=> SUBSTITUTION : la production chute quand le reseau est la.');
|
||||
console.log(`Import moyen en phase fermee : ${n(gOn)} W (consigne ESS ~50 W attendue si le solaire couvre tout).`);
|
||||
}
|
||||
157
tools/test-edf-alterne.mjs
Normal file
157
tools/test-edf-alterne.mjs
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
// Campagne de test EDF <-> MPPT, protocole ALTERNE.
|
||||
//
|
||||
// Usage: node tools/test-edf-alterne.mjs [phaseSec] [sampleSec]
|
||||
// node tools/test-edf-alterne.mjs 180 15 (defaut)
|
||||
//
|
||||
// Protocole : OFF / ON / OFF / ON / OFF, phases de duree egale, echantillon
|
||||
// regulier. L'alternance est ce qui rend la mesure interpretable : une derive
|
||||
// meteo monotone ne peut pas produire un signal qui suit le contacteur quatre
|
||||
// fois de suite.
|
||||
//
|
||||
// Ce que le script mesure a chaque echantillon : PV totale, tension et courant
|
||||
// batterie, consommation maison, import reseau, SOC, limite DVCC, presence
|
||||
// physique AC-IN, et le mode de fonctionnement des 7 chargeurs solaires
|
||||
// (mppt_active = cherche son point de puissance maximale ; sinon il se retient).
|
||||
//
|
||||
// SECURITE : l'hysteresis reseau est desactivee pendant le test puis restauree,
|
||||
// ainsi que le latch et l'etat du contacteur. La restauration est faite aussi
|
||||
// sur Ctrl-C et sur erreur.
|
||||
|
||||
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 = process.env.HA_URL || 'http://10.10.10.17:8123';
|
||||
|
||||
function loadToken() {
|
||||
if (process.env.HA_TOKEN) return process.env.HA_TOKEN;
|
||||
const m = fs.readFileSync(path.join(ROOT, '.env'), 'utf8').match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/m);
|
||||
if (!m) { console.error('HA_TOKEN absent de .env'); process.exit(1); }
|
||||
return m[1].replace(/^["']|["']$/g, '');
|
||||
}
|
||||
const TOKEN = loadToken();
|
||||
const H = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' };
|
||||
|
||||
const get = async (id) => {
|
||||
const r = await fetch(`${HA}/api/states/${id}`, { headers: H });
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()).state;
|
||||
};
|
||||
const call = async (svc, data) => {
|
||||
const [d, s] = svc.split('.');
|
||||
const r = await fetch(`${HA}/api/services/${d}/${s}`, { method: 'POST', headers: H, body: JSON.stringify(data) });
|
||||
if (!r.ok) throw new Error(`${svc} -> HTTP ${r.status} ${await r.text()}`);
|
||||
};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// --- entites -----------------------------------------------------------------
|
||||
const MPPT = [
|
||||
['mppt1', 'sensor.airtria_mppt1_mppt_operation_mode'],
|
||||
['rs_1', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_1_operation_mode'],
|
||||
['rs_2', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_2_operation_mode'],
|
||||
['rs_3', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_3_operation_mode'],
|
||||
['rs_4', 'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_4_operation_mode'],
|
||||
['rs_g', 'sensor.mppt_450_200_hq2449yr9pt_mppt_operation_mode'],
|
||||
['v1', 'sensor.v1_mppt_operation_mode'],
|
||||
];
|
||||
const M = {
|
||||
pv: 'sensor.victron_pv_power_system',
|
||||
u: 'sensor.victron_battery_voltage',
|
||||
i: 'sensor.victron_battery_current',
|
||||
conso: 'sensor.victron_consumption_power_l1',
|
||||
grid: 'sensor.victron_grid_power_l1',
|
||||
soc: 'sensor.victron_battery_soc',
|
||||
dvcc_a: 'sensor.victron_max_charge_current_setting',
|
||||
dvcc_v: 'sensor.victron_max_charge_voltage_setting',
|
||||
acin: 'sensor.victron_ac_in1_available',
|
||||
edf: 'switch.edf_general_ok_switch',
|
||||
mode: 'input_text.victron_charge_mode',
|
||||
};
|
||||
const HYST = 'input_boolean.victron_grid_hysteresis_enabled';
|
||||
const LATCH = 'input_boolean.victron_grid_hysteresis_latch';
|
||||
|
||||
const PHASE_SEC = Number(process.argv[2] || 180);
|
||||
const SAMPLE_SEC = Number(process.argv[3] || 15);
|
||||
const PHASES = ['OFF_1', 'ON_1', 'OFF_2', 'ON_2', 'FIN_OFF'];
|
||||
|
||||
const stamp = () => new Date().toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris' });
|
||||
const out = [];
|
||||
const say = (l) => { console.log(l); out.push(l); };
|
||||
|
||||
// --- restauration ------------------------------------------------------------
|
||||
let initial = null;
|
||||
let restored = false;
|
||||
async function restore(why) {
|
||||
if (restored || !initial) return;
|
||||
restored = true;
|
||||
say(`>>> RESTAURATION (${why})`);
|
||||
try {
|
||||
await call('switch.turn_off', { entity_id: M.edf });
|
||||
await call(`input_boolean.turn_${initial.latch}`, { entity_id: LATCH });
|
||||
await call(`input_boolean.turn_${initial.hyst}`, { entity_id: HYST });
|
||||
say(`>>> etat restaure : edf off | latch ${initial.latch} | hysteresis ${initial.hyst}`);
|
||||
} catch (e) {
|
||||
say(`!!! ECHEC RESTAURATION : ${e.message} — VERIFIER A LA MAIN ${M.edf}, ${LATCH}, ${HYST}`);
|
||||
}
|
||||
}
|
||||
process.on('SIGINT', async () => { await restore('Ctrl-C'); process.exit(130); });
|
||||
|
||||
// --- deroule -----------------------------------------------------------------
|
||||
initial = {
|
||||
hyst: (await get(HYST)) === 'on' ? 'on' : 'off',
|
||||
latch: (await get(LATCH)) === 'on' ? 'on' : 'off',
|
||||
edf: await get(M.edf),
|
||||
};
|
||||
|
||||
say(`TEST ALTERNE EDF <-> MPPT — ${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })}`);
|
||||
say(`phases ${PHASE_SEC}s, echantillon ${SAMPLE_SEC}s, sequence ${PHASES.join(' / ')}`);
|
||||
say(`etat initial : edf ${initial.edf} | latch ${initial.latch} | hysteresis ${initial.hyst}`);
|
||||
say(`consigne reseau ${await get('number.gx_device_ac_power_setpoint')} W | SOC min ESS ${await get('number.gx_device_ess_min_soc_limit')} % | limite active ${await get('sensor.gx_device_active_soc_limit')} %`);
|
||||
say(`MPPT ordre: ${MPPT.map((m) => m[0]).join(' ')} (MPPT=cherche le max, lim_=se retient)`);
|
||||
say('');
|
||||
|
||||
try {
|
||||
await call('input_boolean.turn_off', { entity_id: HYST });
|
||||
await call('switch.turn_off', { entity_id: M.edf });
|
||||
await sleep(2000);
|
||||
|
||||
for (const phase of PHASES) {
|
||||
const want = phase.startsWith('ON') ? 'on' : 'off';
|
||||
if ((await get(M.edf)) !== want) {
|
||||
say(`>>> EDF ${want.toUpperCase()}`);
|
||||
await call(`switch.turn_${want}`, { entity_id: M.edf });
|
||||
await sleep(3000);
|
||||
}
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < PHASE_SEC * 1000) {
|
||||
const v = {};
|
||||
await Promise.all(Object.entries(M).map(async ([k, id]) => { v[k] = await get(id); }));
|
||||
const modes = await Promise.all(MPPT.map(async ([, id]) => {
|
||||
const s = await get(id);
|
||||
return s === 'mppt_active' ? 'MPPT' : (s || 'n/a').slice(0, 4).padEnd(4);
|
||||
}));
|
||||
const n = (x, d = 0) => (Number.isFinite(Number(x)) ? Number(x).toFixed(d) : String(x));
|
||||
say(
|
||||
`${stamp()} | ${phase.padEnd(7)}| PV ${n(v.pv).padStart(5)} | U ${n(v.u, 2).padStart(5)}` +
|
||||
` | I ${n(v.i, 1).padStart(6)} | conso ${n(v.conso).padStart(5)} | grid ${n(v.grid).padStart(5)}` +
|
||||
` | SOC ${n(v.soc)} | DVCC ${n(v.dvcc_a)}A/${n(v.dvcc_v, 1)}V | acin ${v.acin} | edf ${v.edf}` +
|
||||
` | ${modes.join(' ')}`
|
||||
);
|
||||
await sleep(SAMPLE_SEC * 1000);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
say(`!!! ERREUR : ${e.message}`);
|
||||
} finally {
|
||||
await restore('fin de campagne');
|
||||
}
|
||||
|
||||
// Le nom porte la date ET l'heure : deux campagnes le meme jour ne doivent pas
|
||||
// s'ecraser l'une l'autre (piege rencontre le 31/08).
|
||||
const now = new Date();
|
||||
const hhmm = now.toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' }).replace(':', '');
|
||||
const dest = path.join(ROOT, 'docs/mesures', `${now.toISOString().slice(0, 10).replace(/-/g, '')}-${hhmm}-test-edf-alterne.log`);
|
||||
fs.writeFileSync(dest, out.join('\n') + '\n');
|
||||
console.log(`\nJournal : ${dest}`);
|
||||
Reference in New Issue
Block a user