// compare.js - vergleicht die Laermwerte zweier "noise"-API-Instanzen // (z.B. lokaler VictoriaMetrics-Test-Stack vs. Produktion) fuer denselben // Sensor/Zeitraum/Auswertungstyp. Siehe Laerm_API.md fuer die API-Parameter. // // Aufruf: node compare.js [-s sensorid] [-d live|havg|davg|daynight|lden] ... // node compare.js -h fuer alle Optionen import axios from 'axios' import { DateTime } from 'luxon' import mod_getopt from 'posix-getopt' const DEFAULT_URL_A = 'http://localhost:3003/api/getsensordata' const DEFAULT_URL_B = 'https://noise.citysensor.de/api/getsensordata' const DEFAULT_SENSORID = '37833' const DEFAULT_DATA = 'live' const DEFAULT_SPAN = '1' const DEFAULT_PEAK = '70' const DEFAULT_TOLERANCE = 0.05 const MAX_EXAMPLES = 20 function parseArgs(argv) { let opts = { urlA: DEFAULT_URL_A, urlB: DEFAULT_URL_B, sensorid: DEFAULT_SENSORID, data: DEFAULT_DATA, span: DEFAULT_SPAN, datetime: null, peak: DEFAULT_PEAK, tolerance: DEFAULT_TOLERANCE, } let parser = new mod_getopt.BasicParser( 'a:(urlA)b:(urlB)s:(sensorid)d:(data)p:(span)t:(datetime)k:(peak)e:(tolerance)h(help)v(version)', argv ) let option while ((option = parser.getopt()) !== undefined) { switch (option.option) { case 'a': opts.urlA = option.optarg; break case 'b': opts.urlB = option.optarg; break case 's': opts.sensorid = option.optarg; break case 'd': opts.data = option.optarg; break case 'p': opts.span = option.optarg; break case 't': opts.datetime = option.optarg; break case 'k': opts.peak = option.optarg; break case 'e': opts.tolerance = parseFloat(option.optarg); break case 'v': console.log('compare-laermwerte 1.0.0') process.exit() break case 'h': console.log('Usage: node compare.js [options]') console.log('Options:') console.log(` -a urlA Basis-URL Implementierung A (default: ${DEFAULT_URL_A})`) console.log(` -b urlB Basis-URL Implementierung B (default: ${DEFAULT_URL_B})`) console.log(` -s sensorid Sensor-ID (default: ${DEFAULT_SENSORID})`) console.log(` -d data live|havg|davg|daynight|lden (default: ${DEFAULT_DATA})`) console.log(` -p span Zeitspanne in Tagen (default: ${DEFAULT_SPAN})`) console.log(' -t datetime Start-Zeitpunkt ISO8601 (default: unbelegt -> jetzt - span)') console.log(` -k peak dB-Schwelle fuer peakcount, nur havg/davg (default: ${DEFAULT_PEAK})`) console.log(` -e tolerance Toleranz fuer Zahlenvergleich, absolut (default: ${DEFAULT_TOLERANCE})`) console.log(' -v Version anzeigen') console.log(' -h diese Hilfe') process.exit() break default: break } } return opts } function buildURL(base, opts) { let params = { sensorid: opts.sensorid, data: opts.data, span: opts.span, peak: opts.peak, } if (opts.datetime) { params.datetime = opts.datetime } let qs = new URLSearchParams(params).toString() return `${base}?${qs}` } async function fetchSide(label, url) { try { let ret = await axios.get(url, { timeout: 15000 }) if (ret.data && ret.data.err) { return { ok: false, err: `${label}: API-Fehler: ${ret.data.err}` } } return { ok: true, values: ret.data.values || [], options: ret.data.options } } catch (e) { let msg = e.response ? `HTTP ${e.response.status}` : e.message return { ok: false, err: `${label}: Request fehlgeschlagen: ${msg}` } } } function toMap(values) { let map = new Map() for (let row of values) { let ms = DateTime.fromISO(row.datetime, { zone: 'utc' }).toMillis() if (Number.isNaN(ms)) { continue } map.set(ms, row) } return map } function diffRows(mapA, mapB, tolerance) { let onlyA = [] let onlyB = [] let commonCount = 0 let fieldStats = new Map() // field -> {count, sumDiff, maxDiff, mismatchCount} let examples = [] for (let ts of mapA.keys()) { if (!mapB.has(ts)) { onlyA.push(ts) } } for (let ts of mapB.keys()) { if (!mapA.has(ts)) { onlyB.push(ts) } } for (let [ts, rowA] of mapA) { let rowB = mapB.get(ts) if (!rowB) { continue } commonCount++ let fields = new Set([...Object.keys(rowA), ...Object.keys(rowB)]) fields.delete('datetime') for (let field of fields) { let a = (field in rowA) ? rowA[field] : null let b = (field in rowB) ? rowB[field] : null if (a === null && b === null) { continue } let stat = fieldStats.get(field) if (!stat) { stat = { count: 0, sumDiff: 0, maxDiff: 0, mismatchCount: 0 } fieldStats.set(field, stat) } stat.count++ if (typeof a !== 'number' || typeof b !== 'number') { stat.mismatchCount++ if (examples.length < MAX_EXAMPLES) { examples.push({ ts, field, a, b, reason: 'null-mismatch' }) } continue } let diff = Math.abs(a - b) stat.sumDiff += diff if (diff > stat.maxDiff) { stat.maxDiff = diff } if (diff > tolerance) { stat.mismatchCount++ if (examples.length < MAX_EXAMPLES) { examples.push({ ts, field, a, b, diff }) } } } } return { onlyA, onlyB, commonCount, fieldStats, examples } } function fmtTs(ms) { return DateTime.fromMillis(ms, { zone: 'utc' }).toISO() } function printSummary(opts, sideA, sideB, diff) { console.log('=== Vergleich Laermwerte ===') console.log(`Sensor: ${opts.sensorid} data: ${opts.data} span: ${opts.span}${opts.datetime ? ` datetime: ${opts.datetime}` : ''}`) console.log(`A: ${sideA.count} Werte`) console.log(`B: ${sideB.count} Werte`) console.log(`Gemeinsame Zeitstempel: ${diff.commonCount}`) console.log(`Nur in A: ${diff.onlyA.length}${diff.onlyA.length ? ` (z.B. ${diff.onlyA.slice(0, 3).map(fmtTs).join(', ')})` : ''}`) console.log(`Nur in B: ${diff.onlyB.length}${diff.onlyB.length ? ` (z.B. ${diff.onlyB.slice(0, 3).map(fmtTs).join(', ')})` : ''}`) console.log('') console.log('Felder (bei gemeinsamen Zeitstempeln):') let totalMismatches = 0 for (let [field, stat] of diff.fieldStats) { let avg = stat.count ? stat.sumDiff / stat.count : 0 totalMismatches += stat.mismatchCount console.log(` ${field.padEnd(14)} verglichen: ${String(stat.count).padStart(5)} max diff: ${stat.maxDiff.toFixed(4).padStart(10)} avg diff: ${avg.toFixed(4).padStart(10)} mismatches (>${opts.tolerance}): ${stat.mismatchCount}`) } if (diff.examples.length > 0) { console.log('') console.log(`Beispiel-Mismatches (max ${MAX_EXAMPLES}):`) for (let ex of diff.examples) { if (ex.reason === 'null-mismatch') { console.log(` ${fmtTs(ex.ts)} ${ex.field}: A=${ex.a} B=${ex.b}`) } else { console.log(` ${fmtTs(ex.ts)} ${ex.field}: A=${ex.a} B=${ex.b} diff=${ex.diff.toFixed(4)}`) } } } console.log('') if (totalMismatches > 0) { console.log(`FEHLGESCHLAGEN: ${totalMismatches} Feld-Mismatches ausserhalb der Toleranz gefunden.`) } else { console.log('OK: Keine Mismatches ausserhalb der Toleranz bei gemeinsamen Zeitstempeln.') } return totalMismatches } async function main() { let opts = parseArgs(process.argv) let urlA = buildURL(opts.urlA, opts) let urlB = buildURL(opts.urlB, opts) console.log(`A: ${urlA}`) console.log(`B: ${urlB}`) console.log('') let [sideA, sideB] = await Promise.all([ fetchSide('A', urlA), fetchSide('B', urlB), ]) if (!sideA.ok || !sideB.ok) { if (!sideA.ok) console.error(sideA.err) if (!sideB.ok) console.error(sideB.err) process.exitCode = 2 return } let mapA = toMap(sideA.values) let mapB = toMap(sideB.values) let diff = diffRows(mapA, mapB, opts.tolerance) let mismatches = printSummary( opts, { count: sideA.values.length }, { count: sideB.values.length }, diff ) process.exitCode = mismatches > 0 ? 1 : 0 } main().catch((e) => { console.error(e) process.exitCode = 2 })