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>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
// 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
|
||||
})
|
||||
Reference in New Issue
Block a user