#!/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/.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).`); }