Files
laermsensor-stack/compare-laermwerte/compare.js
T
admin 21050f51c9 compare-laermwerte: Testprogramm zum Vergleich von Laermwerten zweier API-Instanzen
Ruft dieselben Parameter (sensorid/data/span/datetime/peak) gegen zwei
"getsensordata"-Endpunkte ab und diffed die Werte zeitstempelweise, generisch
über alle Felder (funktioniert für live genauso wie für havg/davg/daynight/lden).
Diente als Verifikation der VictoriaMetrics-Anbindung: live-Werte stimmen exakt,
havg-Werte liegen weit innerhalb der Toleranz gegenüber der Influx-Produktion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:59:56 +02:00

250 lines
8.9 KiB
JavaScript

// 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
})