From ea906c660ddd055d1eca17e25758e581775b2406 Mon Sep 17 00:00:00 2001 From: Jonny Spicer Date: Fri, 19 Dec 2025 09:58:51 +0000 Subject: [PATCH 1/2] Add synthetic radar detection generation endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements /api/synthetic-detections endpoint for generating realistic radar detections with configurable noise characteristics for testing passive radar tracking systems. Key features: - Configurable Gaussian noise for delay and Doppler measurements - Missed detection simulation (configurable detection probability) - False alarm generation (Poisson-distributed clutter) - Reproducible results via seedable random number generation - Frame-based output format compatible with retina-tracker Implementation includes: - SyntheticRNG class with uniform, Gaussian, Poisson, and Bernoulli distributions - Configuration parsing and validation for noise parameters - Comprehensive test suite for statistical properties - Utility scripts for capturing and processing ADS-B snapshots - Updated README with API documentation and examples Technical changes: - Added seedrandom dependency for reproducible RNG - Changed docker-compose networking from host mode to port mapping - Added data/ directory to .gitignore for snapshot storage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 1 + README.md | 108 +++++++++- capture_snapshots.sh | 54 +++++ docker-compose.yml | 6 +- process_snapshots.js | 223 +++++++++++++++++++ src/node/synthetic.js | 313 +++++++++++++++++++++++++++ src/package.json | 3 +- src/server.js | 180 ++++++++++++++++ test/synthetic.test.js | 476 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1359 insertions(+), 5 deletions(-) create mode 100755 capture_snapshots.sh create mode 100755 process_snapshots.js create mode 100644 src/node/synthetic.js create mode 100644 test/synthetic.test.js diff --git a/.gitignore b/.gitignore index 9a33dfa..ec5d77e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ package-lock.json *.log .env .DS_Store +data/ diff --git a/README.md b/README.md index e3b443b..238b893 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,118 @@ The system architecture is as follows: - The API provides a JSON output in the format `{"":{"timestamp":,"flight":,"delay":,"doppler":}}`. - If no API calls are provided for a set of inputs after 10 minutes, that set will be dropped from the processing loop. +## Synthetic Detection Generation + +The `/api/synthetic-detections` endpoint generates synthetic radar detections with configurable noise characteristics for testing and validation of passive radar tracking systems. This endpoint fetches live ADS-B data and converts it to realistic radar detections with measurement errors, missed detections, and false alarms. + +### Key Features + +- **Configurable Gaussian noise**: Add realistic measurement errors to delay and Doppler +- **Missed detections**: Simulate detection probability < 1.0 +- **False alarms**: Generate Poisson-distributed clutter detections +- **Reproducible**: Seedable random number generation for repeatable tests +- **Extended format**: Outputs frame-based arrays compatible with [retina-tracker](https://github.com/30hours/retina-tracker) + +### API Parameters + +**Required Parameters:** +- `server`: tar1090 or adsb.lol server URL +- `rx`: Receiver coordinates as `lat,lon,alt` (decimal degrees, meters) +- `tx`: Transmitter coordinates as `lat,lon,alt` (decimal degrees, meters) +- `fc`: Transmitter frequency in MHz + +**Optional Noise Parameters:** +- `noise_delay`: Delay noise standard deviation in km (default: 0.5) +- `noise_doppler`: Doppler noise standard deviation in Hz (default: 2.0) +- `snr_min`: Minimum SNR in dB (default: 8) +- `snr_max`: Maximum SNR in dB (default: 20) +- `detection_prob`: Detection probability 0-1 (default: 0.95) +- `false_alarm_rate`: False alarms per frame (default: 0.5) + +**Optional Timing Parameters:** +- `frame_interval`: Frame interval in ms (default: 500) +- `duration`: Total duration in seconds (default: 10) + +**Optional Range Parameters:** +- `delay_min`: Minimum delay for false alarms in km (default: 0) +- `delay_max`: Maximum delay for false alarms in km (default: 400) +- `doppler_min`: Minimum Doppler for false alarms in Hz (default: -200) +- `doppler_max`: Maximum Doppler for false alarms in Hz (default: 200) + +**Optional Reproducibility:** +- `seed`: Random seed for reproducible results (default: current timestamp) + +### Example Usage + +**Basic usage with default noise:** +``` +http://localhost:49155/api/synthetic-detections?server=http://adsb.30hours.dev&rx=51.5074,-0.1278,0&tx=51.5074,-0.0285,0&fc=204.64 +``` + +**Custom noise parameters:** +``` +http://localhost:49155/api/synthetic-detections?server=http://adsb.30hours.dev&rx=51.5074,-0.1278,0&tx=51.5074,-0.0285,0&fc=204.64&noise_delay=1.0&noise_doppler=5.0&detection_prob=0.8&false_alarm_rate=2.0 +``` + +**Reproducible test with seed:** +``` +http://localhost:49155/api/synthetic-detections?server=http://adsb.30hours.dev&rx=51.5074,-0.1278,0&tx=51.5074,-0.0285,0&fc=204.64&seed=test-42 +``` + +### Output Format + +The endpoint returns an array of detection frames in the extended `.detection` format compatible with retina-tracker: + +```json +[ + { + "timestamp": 1718747745000, + "delay": [16.1, 22.3, 15.8], + "doppler": [134.5, -50.2, 88.3], + "snr": [15.2, 12.8, 18.5], + "adsb": [ + { + "hex": "a12345", + "lat": 37.7749, + "lon": -122.4194, + "alt_baro": 5000, + "gs": 250, + "track": 45, + "flight": "UAL123" + }, + { + "hex": "b67890", + "lat": 37.8100, + "lon": -122.3400, + "alt_baro": 8500, + "gs": 300, + "track": 120, + "flight": "DAL456" + }, + null + ] + } +] +``` + +The `adsb` array is parallel to the `delay`, `doppler`, and `snr` arrays. Real aircraft detections include ADS-B metadata for ground truth comparison, while false alarms have `null` entries. + +### Statistical Properties + +The synthetic detections have the following statistical properties: + +- **Delay noise**: Gaussian with mean 0 and standard deviation `noise_delay` km +- **Doppler noise**: Gaussian with mean 0 and standard deviation `noise_doppler` Hz +- **SNR**: Uniform distribution between `snr_min` and `snr_max` dB +- **Detection probability**: Bernoulli trial with probability `detection_prob` per aircraft per frame +- **False alarms**: Poisson-distributed count with rate `false_alarm_rate` per frame +- **False alarm positions**: Uniformly distributed in delay-Doppler space + ## Future Work - Add a 2D plot showing all aircraft in delay-Doppler space. - Add a map showing aircraft in geographic space below the above plot. - Investigate algorithms to accurately compute smooth Doppler values. -- Some functional tests to ensure key features are working. ## License diff --git a/capture_snapshots.sh b/capture_snapshots.sh new file mode 100755 index 0000000..880338a --- /dev/null +++ b/capture_snapshots.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Capture tar1090 snapshots from tar1.retnode.com + +# Configuration +TAR1090_URL="https://tar1.retnode.com/data/aircraft.json" +OUTPUT_DIR="./data/adsb_snapshots" +INTERVAL=1 # seconds between captures +DURATION=300 # total duration in seconds (5 minutes default) + +# Parse command line argument for duration +if [ $# -eq 1 ]; then + DURATION=$1 +fi + +# Create output directory +mkdir -p "$OUTPUT_DIR" + +echo "Capturing ADS-B snapshots from tar1.retnode.com" +echo "Duration: ${DURATION} seconds" +echo "Output directory: $OUTPUT_DIR" +echo "" + +START_TIME=$(date +%s) +END_TIME=$((START_TIME + DURATION)) +COUNT=0 + +while [ $(date +%s) -lt $END_TIME ]; do + TIMESTAMP=$(date +%s)000 # milliseconds (approximate) + OUTPUT_FILE="${OUTPUT_DIR}/aircraft_${TIMESTAMP}.json" + + # Fetch and save + if curl -s "$TAR1090_URL" > "$OUTPUT_FILE" 2>/dev/null; then + COUNT=$((COUNT + 1)) + ELAPSED=$(($(date +%s) - START_TIME)) + + # Count aircraft in this snapshot + AIRCRAFT_COUNT=$(grep -o '"hex"' "$OUTPUT_FILE" | wc -l | tr -d ' ') + + echo -ne "\rCaptured: ${COUNT} snapshots | Elapsed: ${ELAPSED}/${DURATION}s | Aircraft: ${AIRCRAFT_COUNT} " + else + echo -e "\nWarning: Failed to fetch data at $(date)" + fi + + sleep $INTERVAL +done + +echo -e "\n" +echo "Done!" +echo "Total snapshots: $COUNT" +echo "Output directory: $OUTPUT_DIR" +echo "Total size: $(du -sh $OUTPUT_DIR | cut -f1)" +echo "" +echo "Sample snapshot:" +ls -lh "$OUTPUT_DIR" | head -3 diff --git a/docker-compose.yml b/docker-compose.yml index bc86433..ddb35ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,9 +2,9 @@ version: '3' services: adsb2dd: restart: always - build: + build: context: . - network: host image: adsb2dd - network_mode: host container_name: adsb2dd + ports: + - "49155:49155" diff --git a/process_snapshots.js b/process_snapshots.js new file mode 100755 index 0000000..1504049 --- /dev/null +++ b/process_snapshots.js @@ -0,0 +1,223 @@ +#!/usr/bin/env node +// Convert captured tar1090 snapshots to synthetic detection data + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Import from src/node +import { lla2ecef, norm } from './src/node/geometry.js'; +import { calculateDopplerFromVelocity } from './src/node/doppler.js'; +import { + SyntheticRNG, + parseSyntheticConfig, + validateSyntheticConfig, + generateSyntheticFrame +} from './src/node/synthetic.js'; + +// Configuration +const SNAPSHOTS_DIR = './data/adsb_snapshots'; +const OUTPUT_FILE = './data/synthetic_historical.detection'; + +// Radar parameters from blah2 config +const RX_LAT = 37.7644; // 150 Mississippi +const RX_LON = -122.3954; +const RX_ALT = 23; // meters + +const TX_LAT = 37.49917; // KSCZ-LD +const TX_LON = -121.87222; +const TX_ALT = 783; // meters + +const FC = 503; // MHz + +// Synthetic noise configuration +const SYNTHETIC_CONFIG = { + noise_delay: 0.5, + noise_doppler: 2.0, + snr_min: 8, + snr_max: 20, + detection_prob: 0.95, + false_alarm_rate: 0.5, + delay_min: 0, + delay_max: 400, + doppler_min: -200, + doppler_max: 200, + seed: 'historical-test-123' +}; + +function ft2m(ft) { + return ft * 0.3048; +} + +function processSnapshots() { + console.log('Processing ADS-B snapshots to synthetic detections...\n'); + + // Pre-compute ECEF coordinates and baseline + const ecefRx = lla2ecef(RX_LAT, RX_LON, RX_ALT); + const ecefTx = lla2ecef(TX_LAT, TX_LON, TX_ALT); + const dRxTx = norm({ + x: ecefRx.x - ecefTx.x, + y: ecefRx.y - ecefTx.y, + z: ecefRx.z - ecefTx.z + }); + + console.log('Radar Configuration:'); + console.log(` RX: ${RX_LAT}, ${RX_LON}, ${RX_ALT}m`); + console.log(` TX: ${TX_LAT}, ${TX_LON}, ${TX_ALT}m`); + console.log(` Baseline: ${dRxTx.toFixed(2)} m`); + console.log(` Frequency: ${FC} MHz\n`); + + // Initialize RNG + const rng = new SyntheticRNG(SYNTHETIC_CONFIG.seed); + + // Read all snapshot files + const files = fs.readdirSync(SNAPSHOTS_DIR) + .filter(f => f.startsWith('aircraft_') && f.endsWith('.json')) + .sort(); + + console.log(`Found ${files.length} snapshot files\n`); + + if (files.length === 0) { + console.error('No snapshot files found!'); + process.exit(1); + } + + // Process each snapshot + const detectionFrames = []; + let processedCount = 0; + + for (const file of files) { + const filePath = path.join(SNAPSHOTS_DIR, file); + const timestamp = parseInt(file.match(/aircraft_(\d+)\.json/)[1]); + + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + + if (!data.aircraft) { + console.warn(`Skipping ${file}: no aircraft array`); + continue; + } + + // Compute delay-Doppler for each aircraft + const aircraftDict = {}; + + for (const aircraft of data.aircraft) { + // Skip if missing required fields + if (!aircraft.hex || !aircraft.lat || !aircraft.lon || !aircraft.alt_geom) { + continue; + } + + // Compute ECEF position + const tar = lla2ecef(aircraft.lat, aircraft.lon, ft2m(aircraft.alt_geom)); + + // Compute distances + const dRxTar = norm({ + x: ecefRx.x - tar.x, + y: ecefRx.y - tar.y, + z: ecefRx.z - tar.z + }); + + const dTxTar = norm({ + x: ecefTx.x - tar.x, + y: ecefTx.y - tar.y, + z: ecefTx.z - tar.z + }); + + // Compute delay + const delay = (dRxTar + dTxTar - dRxTx) / 1000; + + // Compute Doppler (if velocity available) + let doppler = null; + if (aircraft.gs !== undefined && aircraft.track !== undefined) { + doppler = calculateDopplerFromVelocity( + aircraft, tar, ecefRx, ecefTx, dRxTar, dTxTar, FC + ); + } + + // Skip if invalid + if (!delay || !doppler || delay < 0) { + continue; + } + + aircraftDict[aircraft.hex] = { + delay: delay, + doppler: doppler, + flight: aircraft.flight || aircraft.hex, + lat: aircraft.lat, + lon: aircraft.lon, + alt_baro: aircraft.alt_baro || aircraft.alt_geom, + gs: aircraft.gs, + track: aircraft.track + }; + } + + // Generate synthetic frame with noise + const frame = generateSyntheticFrame(aircraftDict, timestamp, SYNTHETIC_CONFIG, rng); + + // Add full ADS-B metadata + for (let i = 0; i < frame.adsb.length; i++) { + if (frame.adsb[i] !== null) { + const hex = frame.adsb[i].hex; + const aircraft = aircraftDict[hex]; + if (aircraft) { + frame.adsb[i] = { + hex: hex, + lat: aircraft.lat, + lon: aircraft.lon, + alt_baro: aircraft.alt_baro, + gs: aircraft.gs, + track: aircraft.track, + flight: aircraft.flight + }; + } + } + } + + detectionFrames.push(frame); + processedCount++; + + if (processedCount % 10 === 0) { + process.stdout.write(`\rProcessed: ${processedCount}/${files.length} frames`); + } + + } catch (err) { + console.warn(`\nError processing ${file}: ${err.message}`); + } + } + + console.log(`\rProcessed: ${processedCount}/${files.length} frames\n`); + + // Write output file (one frame per line) + const outputLines = detectionFrames.map(frame => JSON.stringify(frame)); + fs.writeFileSync(OUTPUT_FILE, outputLines.join('\n') + '\n'); + + console.log(`Output: ${OUTPUT_FILE}`); + console.log(`Total frames: ${detectionFrames.length}`); + console.log(`File size: ${(fs.statSync(OUTPUT_FILE).size / 1024).toFixed(2)} KB\n`); + + // Show statistics + const totalDetections = detectionFrames.reduce((sum, f) => sum + f.delay.length, 0); + const totalAircraft = detectionFrames.reduce((sum, f) => + sum + f.adsb.filter(a => a !== null).length, 0 + ); + const totalFalseAlarms = detectionFrames.reduce((sum, f) => + sum + f.adsb.filter(a => a === null).length, 0 + ); + + console.log('Statistics:'); + console.log(` Total detections: ${totalDetections}`); + console.log(` Aircraft detections: ${totalAircraft}`); + console.log(` False alarms: ${totalFalseAlarms}`); + console.log(` Avg detections/frame: ${(totalDetections / detectionFrames.length).toFixed(1)}`); + console.log(''); + console.log('Ready to test with retina-tracker!'); + console.log(` cd ../retina-tracker`); + console.log(` python -m tracker.track_detections ../adsb2dd/${OUTPUT_FILE} -o output/tracks.json -v output/tracks.png`); +} + +// Run +processSnapshots(); diff --git a/src/node/synthetic.js b/src/node/synthetic.js new file mode 100644 index 0000000..0926558 --- /dev/null +++ b/src/node/synthetic.js @@ -0,0 +1,313 @@ +/// @file Synthetic detection generation with configurable noise +/// @brief Utilities for generating realistic radar detections with noise/imperfections + +import seedrandom from 'seedrandom'; + +/// @brief Random number generator with various distributions +export class SyntheticRNG { + constructor(seed) { + this.rng = seedrandom(seed !== undefined ? seed : Date.now().toString()); + } + + /// @brief Generate uniform random number in [min, max] + uniform(min, max) { + return min + this.rng() * (max - min); + } + + /// @brief Generate Gaussian (normal) random number using Box-Muller transform + /// @param mean Mean of distribution + /// @param std Standard deviation + /// @return Random number from N(mean, std^2) + gaussian(mean = 0, std = 1) { + const u1 = this.rng(); + const u2 = this.rng(); + const z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2); + return mean + z0 * std; + } + + /// @brief Generate Poisson random variable + /// @param lambda Expected value (rate parameter) + /// @return Random integer from Poisson(lambda) + poisson(lambda) { + if (lambda <= 0) return 0; + + const L = Math.exp(-lambda); + let k = 0; + let p = 1; + + do { + k++; + p *= this.rng(); + } while (p > L); + + return k - 1; + } + + /// @brief Bernoulli trial (coin flip with probability p) + /// @param p Probability of success [0-1] + /// @return true with probability p + bernoulli(p) { + return this.rng() < p; + } +} + +/// @brief Default configuration for synthetic detection generation +export const DEFAULT_SYNTHETIC_CONFIG = { + noise_delay: 0.5, // Delay noise std (km) + noise_doppler: 2.0, // Doppler noise std (Hz) + snr_min: 8, // Minimum SNR (dB) + snr_max: 20, // Maximum SNR (dB) + detection_prob: 0.95, // Probability of detecting aircraft [0-1] + false_alarm_rate: 0.5, // False alarms per frame + frame_interval: 500, // Frame interval (ms) + duration: 10, // Total duration (seconds) + delay_min: 0, // Min delay for false alarms (km) + delay_max: 400, // Max delay for false alarms (km) + doppler_min: -200, // Min Doppler for false alarms (Hz) + doppler_max: 200 // Max Doppler for false alarms (Hz) +}; + +/// @brief Parse synthetic configuration from query parameters +/// @param query Express query object +/// @return Configuration object with defaults applied +export function parseSyntheticConfig(query) { + const config = { ...DEFAULT_SYNTHETIC_CONFIG }; + + if (query.noise_delay !== undefined) { + config.noise_delay = parseFloat(query.noise_delay); + } + if (query.noise_doppler !== undefined) { + config.noise_doppler = parseFloat(query.noise_doppler); + } + if (query.snr_min !== undefined) { + config.snr_min = parseFloat(query.snr_min); + } + if (query.snr_max !== undefined) { + config.snr_max = parseFloat(query.snr_max); + } + if (query.detection_prob !== undefined) { + config.detection_prob = parseFloat(query.detection_prob); + } + if (query.false_alarm_rate !== undefined) { + config.false_alarm_rate = parseFloat(query.false_alarm_rate); + } + if (query.frame_interval !== undefined) { + config.frame_interval = parseInt(query.frame_interval); + } + if (query.duration !== undefined) { + config.duration = parseFloat(query.duration); + } + if (query.delay_min !== undefined) { + config.delay_min = parseFloat(query.delay_min); + } + if (query.delay_max !== undefined) { + config.delay_max = parseFloat(query.delay_max); + } + if (query.doppler_min !== undefined) { + config.doppler_min = parseFloat(query.doppler_min); + } + if (query.doppler_max !== undefined) { + config.doppler_max = parseFloat(query.doppler_max); + } + if (query.seed !== undefined) { + config.seed = query.seed; + } + + return config; +} + +/// @brief Validate synthetic configuration +/// @param config Configuration object +/// @return Object with {valid: boolean, errors: string[]} +export function validateSyntheticConfig(config) { + const errors = []; + + if (config.noise_delay < 0) { + errors.push('noise_delay must be non-negative'); + } + if (config.noise_doppler < 0) { + errors.push('noise_doppler must be non-negative'); + } + if (config.snr_min > config.snr_max) { + errors.push('snr_min must be <= snr_max'); + } + if (config.detection_prob < 0 || config.detection_prob > 1) { + errors.push('detection_prob must be in [0, 1]'); + } + if (config.false_alarm_rate < 0) { + errors.push('false_alarm_rate must be non-negative'); + } + if (config.frame_interval <= 0) { + errors.push('frame_interval must be positive'); + } + if (config.duration <= 0) { + errors.push('duration must be positive'); + } + if (config.delay_min >= config.delay_max) { + errors.push('delay_min must be < delay_max'); + } + if (config.doppler_min >= config.doppler_max) { + errors.push('doppler_min must be < doppler_max'); + } + + return { + valid: errors.length === 0, + errors: errors + }; +} + +/// @brief Generate a single synthetic detection frame from aircraft data +/// @param aircraftDict Per-aircraft delay-Doppler data from adsb2dd +/// @param timestamp Frame timestamp (ms) +/// @param config Synthetic configuration +/// @param rng Random number generator +/// @return Frame object with {timestamp, delay, doppler, snr, adsb} +export function generateSyntheticFrame(aircraftDict, timestamp, config, rng) { + const delays = []; + const dopplers = []; + const snrs = []; + const adsb = []; + + // Process each aircraft + for (const [hex, data] of Object.entries(aircraftDict)) { + // Skip if no delay/Doppler data yet + if (data.delay === undefined || data.doppler === undefined) { + continue; + } + + // Simulate missed detection + if (!rng.bernoulli(config.detection_prob)) { + continue; + } + + // True delay and Doppler + const trueDelay = data.delay; + const trueDoppler = data.doppler; + + // Add Gaussian noise + const noisyDelay = trueDelay + rng.gaussian(0, config.noise_delay); + const noisyDoppler = trueDoppler + rng.gaussian(0, config.noise_doppler); + + // Generate realistic SNR + const snr = rng.uniform(config.snr_min, config.snr_max); + + delays.push(noisyDelay); + dopplers.push(noisyDoppler); + snrs.push(snr); + + // Include ADS-B data for validation + // Note: Need to get original aircraft data (lat/lon/alt/gs/track) + // This will be populated by the endpoint logic + adsb.push({ + hex: hex, + flight: data.flight + // lat, lon, alt_baro, gs, track will be added by endpoint + }); + } + + // Add false alarms (clutter) + const nFalseAlarms = rng.poisson(config.false_alarm_rate); + for (let i = 0; i < nFalseAlarms; i++) { + delays.push(rng.uniform(config.delay_min, config.delay_max)); + dopplers.push(rng.uniform(config.doppler_min, config.doppler_max)); + // Lower SNR for clutter (typically weaker) + snrs.push(rng.uniform(config.snr_min, config.snr_max * 0.7)); + adsb.push(null); // No ADS-B match for clutter + } + + return { + timestamp: timestamp, + delay: delays, + doppler: dopplers, + snr: snrs, + adsb: adsb + }; +} + +/// @brief Generate complete synthetic detection dataset +/// @param getAircraftData Async function to fetch aircraft data +/// @param config Synthetic configuration +/// @return Array of detection frames +export async function generateSyntheticDataset(getAircraftData, config) { + const rng = new SyntheticRNG(config.seed); + const frames = []; + + const nFrames = Math.ceil((config.duration * 1000) / config.frame_interval); + + for (let i = 0; i < nFrames; i++) { + const timestamp = Date.now() + i * config.frame_interval; + + // Fetch current aircraft data + const aircraftDict = await getAircraftData(); + + // Generate synthetic frame + const frame = generateSyntheticFrame(aircraftDict, timestamp, config, rng); + frames.push(frame); + + // Wait for frame interval (if generating in real-time) + // For batch generation, skip this + } + + return frames; +} + +/// @brief Convert per-aircraft dict to frame-based arrays +/// @param aircraftDict Per-aircraft data from adsb2dd +/// @param aircraftRawData Raw aircraft data from tar1090/adsblol +/// @param timestamp Frame timestamp +/// @return Frame object with arrays +export function convertToFrameFormat(aircraftDict, aircraftRawData, timestamp) { + const delays = []; + const dopplers = []; + const snrs = []; + const adsb = []; + + // Create a map of hex codes to raw data for easy lookup + const rawDataMap = {}; + if (aircraftRawData && aircraftRawData.aircraft) { + for (const aircraft of aircraftRawData.aircraft) { + rawDataMap[aircraft.hex] = aircraft; + } + } + + for (const [hex, data] of Object.entries(aircraftDict)) { + if (data.delay === undefined || data.doppler === undefined) { + continue; + } + + delays.push(data.delay); + dopplers.push(data.doppler); + + // Use a reasonable default SNR since we don't have real SNR data + // In reality, SNR would come from the radar receiver + snrs.push(15.0); + + // Get raw aircraft data for ADS-B fields + const rawAircraft = rawDataMap[hex]; + if (rawAircraft) { + adsb.push({ + hex: hex, + lat: rawAircraft.lat, + lon: rawAircraft.lon, + alt_baro: rawAircraft.alt_baro || rawAircraft.alt_geom, + gs: rawAircraft.gs, + track: rawAircraft.track, + flight: data.flight + }); + } else { + // Fallback if raw data not available + adsb.push({ + hex: hex, + flight: data.flight + }); + } + } + + return { + timestamp: timestamp, + delay: delays, + doppler: dopplers, + snr: snrs, + adsb: adsb + }; +} diff --git a/src/package.json b/src/package.json index fe467c0..1c8b613 100644 --- a/src/package.json +++ b/src/package.json @@ -12,7 +12,8 @@ "dependencies": { "cors": "^2.8.5", "express": "^4.16.1", - "node-fetch": "^3.3.2" + "node-fetch": "^3.3.2", + "seedrandom": "^3.0.5" }, "devDependencies": { "jest": "^30.2.0" diff --git a/src/server.js b/src/server.js index cf20ee2..57d9aba 100644 --- a/src/server.js +++ b/src/server.js @@ -8,6 +8,8 @@ import {checkAdsbLol, getAdsbLol} from './node/adsblol.js'; import {lla2ecef, norm, ft2m} from './node/geometry.js'; import {isValidNumber} from './node/validate.js'; import {calculateDopplerFromVelocity, calculateWavelength} from './node/doppler.js'; +import {SyntheticRNG, parseSyntheticConfig, validateSyntheticConfig, + generateSyntheticFrame, convertToFrameFormat} from './node/synthetic.js'; const resolve4 = promisify(dns.resolve4); const resolve6 = promisify(dns.resolve6); @@ -227,6 +229,184 @@ app.get('/api/dd', async (req, res) => { }); +app.get('/api/synthetic-detections', async (req, res) => { + // Parse synthetic configuration + const syntheticConfig = parseSyntheticConfig(req.query); + + // Validate configuration + const validation = validateSyntheticConfig(syntheticConfig); + if (!validation.valid) { + return res.status(400).json({ + error: 'Invalid synthetic configuration', + details: validation.errors + }); + } + + // Validate regular parameters (same as /api/dd) + const server = req.query.server; + const rxParams = req.query.rx?.split(',').map(parseFloat); + const txParams = req.query.tx?.split(',').map(parseFloat); + const fc = parseFloat(req.query.fc); + + if (!server || !rxParams || !txParams || !rxParams.every(isValidNumber) || + !txParams.every(isValidNumber) || isNaN(fc) || fc <= 0) { + return res.status(400).json({ + error: 'Invalid parameters. Required: server, rx, tx, fc' + }); + } + + const [rxLat, rxLon, rxAlt] = rxParams; + const [txLat, txLon, txAlt] = txParams; + + // Validate server URL (reuse logic from /api/dd) + let serverUrl; + try { + serverUrl = new URL(server); + } catch (e) { + return res.status(400).json({ error: 'Invalid server URL format' }); + } + + if (!['http:', 'https:'].includes(serverUrl.protocol)) { + return res.status(400).json({ + error: 'Server URL must use http or https protocol' + }); + } + + const isAdsbLol = serverUrl.hostname === 'api.adsb.lol'; + + // Initialize RNG + const rng = new SyntheticRNG(syntheticConfig.seed); + + // Pre-compute ECEF coordinates + const ecefRx = lla2ecef(rxLat, rxLon, rxAlt); + const ecefTx = lla2ecef(txLat, txLon, txAlt); + const dRxTx = norm([ecefRx.x - ecefTx.x, ecefRx.y - ecefTx.y, + ecefRx.z - ecefTx.z]); + + // Generate frames + const frames = []; + const nFrames = Math.ceil((syntheticConfig.duration * 1000) / + syntheticConfig.frame_interval); + + for (let i = 0; i < nFrames; i++) { + const timestamp = Date.now() + i * syntheticConfig.frame_interval; + + // Fetch aircraft data + let json; + if (isAdsbLol) { + const midLat = (rxLat + txLat) / 2; + const midLon = (rxLon + txLon) / 2; + json = await getAdsbLol(midLat, midLon, adsbLolRadius); + } else { + const apiUrl = new URL('/data/aircraft.json', server).href; + json = await getTar1090(apiUrl); + } + + if (!json || !json.aircraft || !Array.isArray(json.aircraft)) { + // No aircraft data - generate empty frame or skip + frames.push({ + timestamp: timestamp, + delay: [], + doppler: [], + snr: [], + adsb: [] + }); + continue; + } + + // Compute delay-Doppler for all aircraft + const aircraftDict = {}; + for (const aircraft of json.aircraft) { + const isValidAircraft = isValidNumber(aircraft['lat']) && + isValidNumber(aircraft['lon']) && + isValidNumber(aircraft['alt_geom']) && + (aircraft['flight'] != undefined); + + if (!isValidAircraft) { + continue; + } + + const hexCode = aircraft.hex; + const tar = lla2ecef(aircraft['lat'], aircraft['lon'], + ft2m(aircraft['alt_geom'])); + + const dRxTar = norm([ecefRx.x - tar.x, ecefRx.y - tar.y, + ecefRx.z - tar.z]); + const dTxTar = norm([ecefTx.x - tar.x, ecefTx.y - tar.y, + ecefTx.z - tar.z]); + const delay = (dRxTar + dTxTar - dRxTx) / 1000; // Convert to km + + const doppler = calculateDopplerFromVelocity( + aircraft, tar, ecefRx, ecefTx, dRxTar, dTxTar, fc + ); + + if (doppler !== null) { + aircraftDict[hexCode] = { + delay: delay, + doppler: doppler, + flight: aircraft.flight, + lat: aircraft.lat, + lon: aircraft.lon, + alt_baro: aircraft.alt_baro || aircraft.alt_geom, + gs: aircraft.gs, + track: aircraft.track + }; + } + } + + // Generate synthetic frame with noise + const delays = []; + const dopplers = []; + const snrs = []; + const adsb = []; + + // Add detections for each aircraft + for (const [hex, data] of Object.entries(aircraftDict)) { + // Simulate missed detection + if (!rng.bernoulli(syntheticConfig.detection_prob)) { + continue; + } + + // Add Gaussian noise + const noisyDelay = data.delay + rng.gaussian(0, syntheticConfig.noise_delay); + const noisyDoppler = data.doppler + rng.gaussian(0, syntheticConfig.noise_doppler); + const snr = rng.uniform(syntheticConfig.snr_min, syntheticConfig.snr_max); + + delays.push(noisyDelay); + dopplers.push(noisyDoppler); + snrs.push(snr); + adsb.push({ + hex: hex, + lat: data.lat, + lon: data.lon, + alt_baro: data.alt_baro, + gs: data.gs, + track: data.track, + flight: data.flight + }); + } + + // Add false alarms (clutter) + const nFalseAlarms = rng.poisson(syntheticConfig.false_alarm_rate); + for (let j = 0; j < nFalseAlarms; j++) { + delays.push(rng.uniform(syntheticConfig.delay_min, syntheticConfig.delay_max)); + dopplers.push(rng.uniform(syntheticConfig.doppler_min, syntheticConfig.doppler_max)); + snrs.push(rng.uniform(syntheticConfig.snr_min, syntheticConfig.snr_max * 0.7)); + adsb.push(null); + } + + frames.push({ + timestamp: timestamp, + delay: delays, + doppler: dopplers, + snr: snrs, + adsb: adsb + }); + } + + return res.json(frames); +}); + const host = process.env.HOST || '0.0.0.0'; app.listen(port, host, () => { console.log(`Server is running at http://${host}:${port}`); diff --git a/test/synthetic.test.js b/test/synthetic.test.js new file mode 100644 index 0000000..f8e7c36 --- /dev/null +++ b/test/synthetic.test.js @@ -0,0 +1,476 @@ +import { + SyntheticRNG, + DEFAULT_SYNTHETIC_CONFIG, + parseSyntheticConfig, + validateSyntheticConfig, + generateSyntheticFrame, + convertToFrameFormat +} from '../src/node/synthetic.js'; + +describe('Synthetic Detection Generation', () => { + describe('SyntheticRNG', () => { + test('uniform distribution produces values in range', () => { + const rng = new SyntheticRNG(42); + const samples = Array.from({length: 1000}, () => rng.uniform(10, 20)); + + expect(Math.min(...samples)).toBeGreaterThanOrEqual(10); + expect(Math.max(...samples)).toBeLessThanOrEqual(20); + + const mean = samples.reduce((a, b) => a + b, 0) / samples.length; + expect(mean).toBeCloseTo(15, 0); + }); + + test('gaussian distribution has correct statistics', () => { + const rng = new SyntheticRNG(42); + const mean = 5.0; + const std = 2.0; + const samples = Array.from({length: 10000}, () => rng.gaussian(mean, std)); + + const sampleMean = samples.reduce((a, b) => a + b, 0) / samples.length; + const sampleStd = Math.sqrt( + samples.reduce((sum, x) => sum + (x - sampleMean) ** 2, 0) / samples.length + ); + + expect(sampleMean).toBeCloseTo(mean, 1); + expect(sampleStd).toBeCloseTo(std, 1); + }); + + test('poisson distribution has correct mean', () => { + const rng = new SyntheticRNG(42); + const lambda = 5.0; + const samples = Array.from({length: 10000}, () => rng.poisson(lambda)); + + const sampleMean = samples.reduce((a, b) => a + b, 0) / samples.length; + expect(sampleMean).toBeCloseTo(lambda, 0); + }); + + test('poisson with lambda=0 returns 0', () => { + const rng = new SyntheticRNG(42); + expect(rng.poisson(0)).toBe(0); + expect(rng.poisson(-1)).toBe(0); + }); + + test('bernoulli produces correct probability', () => { + const rng = new SyntheticRNG(42); + const p = 0.7; + const samples = Array.from({length: 10000}, () => rng.bernoulli(p)); + + const successRate = samples.filter(x => x).length / samples.length; + expect(successRate).toBeCloseTo(p, 1); + }); + + test('seeded RNG produces reproducible results', () => { + const rng1 = new SyntheticRNG('test-seed'); + const rng2 = new SyntheticRNG('test-seed'); + + const samples1 = Array.from({length: 100}, () => rng1.uniform(0, 1)); + const samples2 = Array.from({length: 100}, () => rng2.uniform(0, 1)); + + for (let i = 0; i < samples1.length; i++) { + expect(samples1[i]).toBe(samples2[i]); + } + }); + + test('different seeds produce different sequences', () => { + const rng1 = new SyntheticRNG('seed1'); + const rng2 = new SyntheticRNG('seed2'); + + const samples1 = Array.from({length: 10}, () => rng1.uniform(0, 1)); + const samples2 = Array.from({length: 10}, () => rng2.uniform(0, 1)); + + const allDifferent = samples1.some((val, idx) => val !== samples2[idx]); + expect(allDifferent).toBe(true); + }); + }); + + describe('Configuration parsing', () => { + test('uses defaults when no query parameters provided', () => { + const config = parseSyntheticConfig({}); + expect(config).toEqual(DEFAULT_SYNTHETIC_CONFIG); + }); + + test('parses noise_delay parameter', () => { + const config = parseSyntheticConfig({noise_delay: '1.5'}); + expect(config.noise_delay).toBe(1.5); + }); + + test('parses noise_doppler parameter', () => { + const config = parseSyntheticConfig({noise_doppler: '3.0'}); + expect(config.noise_doppler).toBe(3.0); + }); + + test('parses SNR parameters', () => { + const config = parseSyntheticConfig({snr_min: '10', snr_max: '25'}); + expect(config.snr_min).toBe(10); + expect(config.snr_max).toBe(25); + }); + + test('parses detection probability', () => { + const config = parseSyntheticConfig({detection_prob: '0.8'}); + expect(config.detection_prob).toBe(0.8); + }); + + test('parses false alarm rate', () => { + const config = parseSyntheticConfig({false_alarm_rate: '2.0'}); + expect(config.false_alarm_rate).toBe(2.0); + }); + + test('parses frame interval and duration', () => { + const config = parseSyntheticConfig({frame_interval: '1000', duration: '30'}); + expect(config.frame_interval).toBe(1000); + expect(config.duration).toBe(30); + }); + + test('parses delay and doppler ranges', () => { + const config = parseSyntheticConfig({ + delay_min: '50', + delay_max: '500', + doppler_min: '-300', + doppler_max: '300' + }); + expect(config.delay_min).toBe(50); + expect(config.delay_max).toBe(500); + expect(config.doppler_min).toBe(-300); + expect(config.doppler_max).toBe(300); + }); + + test('parses seed parameter', () => { + const config = parseSyntheticConfig({seed: 'test-seed-123'}); + expect(config.seed).toBe('test-seed-123'); + }); + }); + + describe('Configuration validation', () => { + test('validates default configuration', () => { + const result = validateSyntheticConfig(DEFAULT_SYNTHETIC_CONFIG); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + test('rejects negative noise_delay', () => { + const config = {...DEFAULT_SYNTHETIC_CONFIG, noise_delay: -1}; + const result = validateSyntheticConfig(config); + expect(result.valid).toBe(false); + expect(result.errors).toContain('noise_delay must be non-negative'); + }); + + test('rejects negative noise_doppler', () => { + const config = {...DEFAULT_SYNTHETIC_CONFIG, noise_doppler: -1}; + const result = validateSyntheticConfig(config); + expect(result.valid).toBe(false); + expect(result.errors).toContain('noise_doppler must be non-negative'); + }); + + test('rejects snr_min > snr_max', () => { + const config = {...DEFAULT_SYNTHETIC_CONFIG, snr_min: 25, snr_max: 10}; + const result = validateSyntheticConfig(config); + expect(result.valid).toBe(false); + expect(result.errors).toContain('snr_min must be <= snr_max'); + }); + + test('rejects invalid detection_prob', () => { + const config1 = {...DEFAULT_SYNTHETIC_CONFIG, detection_prob: -0.1}; + const result1 = validateSyntheticConfig(config1); + expect(result1.valid).toBe(false); + expect(result1.errors).toContain('detection_prob must be in [0, 1]'); + + const config2 = {...DEFAULT_SYNTHETIC_CONFIG, detection_prob: 1.5}; + const result2 = validateSyntheticConfig(config2); + expect(result2.valid).toBe(false); + expect(result2.errors).toContain('detection_prob must be in [0, 1]'); + }); + + test('rejects negative false_alarm_rate', () => { + const config = {...DEFAULT_SYNTHETIC_CONFIG, false_alarm_rate: -1}; + const result = validateSyntheticConfig(config); + expect(result.valid).toBe(false); + expect(result.errors).toContain('false_alarm_rate must be non-negative'); + }); + + test('rejects invalid frame_interval', () => { + const config = {...DEFAULT_SYNTHETIC_CONFIG, frame_interval: 0}; + const result = validateSyntheticConfig(config); + expect(result.valid).toBe(false); + expect(result.errors).toContain('frame_interval must be positive'); + }); + + test('rejects invalid duration', () => { + const config = {...DEFAULT_SYNTHETIC_CONFIG, duration: 0}; + const result = validateSyntheticConfig(config); + expect(result.valid).toBe(false); + expect(result.errors).toContain('duration must be positive'); + }); + + test('rejects invalid delay range', () => { + const config = {...DEFAULT_SYNTHETIC_CONFIG, delay_min: 100, delay_max: 50}; + const result = validateSyntheticConfig(config); + expect(result.valid).toBe(false); + expect(result.errors).toContain('delay_min must be < delay_max'); + }); + + test('rejects invalid doppler range', () => { + const config = {...DEFAULT_SYNTHETIC_CONFIG, doppler_min: 100, doppler_max: -100}; + const result = validateSyntheticConfig(config); + expect(result.valid).toBe(false); + expect(result.errors).toContain('doppler_min must be < doppler_max'); + }); + + test('accumulates multiple validation errors', () => { + const config = { + ...DEFAULT_SYNTHETIC_CONFIG, + noise_delay: -1, + detection_prob: 2.0, + duration: -5 + }; + const result = validateSyntheticConfig(config); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe('Frame generation', () => { + test('generates frame with aircraft detections', () => { + const aircraftDict = { + 'abc123': { + delay: 100.0, + doppler: 50.0, + flight: 'UAL123' + }, + 'def456': { + delay: 200.0, + doppler: -75.0, + flight: 'DAL456' + } + }; + + const config = {...DEFAULT_SYNTHETIC_CONFIG, detection_prob: 1.0, false_alarm_rate: 0}; + const rng = new SyntheticRNG(42); + const timestamp = Date.now(); + + const frame = generateSyntheticFrame(aircraftDict, timestamp, config, rng); + + expect(frame.timestamp).toBe(timestamp); + expect(frame.delay.length).toBe(2); + expect(frame.doppler.length).toBe(2); + expect(frame.snr.length).toBe(2); + expect(frame.adsb.length).toBe(2); + + expect(frame.snr[0]).toBeGreaterThanOrEqual(config.snr_min); + expect(frame.snr[0]).toBeLessThanOrEqual(config.snr_max); + }); + + test('applies gaussian noise to measurements', () => { + const aircraftDict = { + 'abc123': { + delay: 100.0, + doppler: 50.0, + flight: 'UAL123' + } + }; + + const config = { + ...DEFAULT_SYNTHETIC_CONFIG, + noise_delay: 0.5, + noise_doppler: 2.0, + detection_prob: 1.0, + false_alarm_rate: 0 + }; + + const frames = []; + for (let i = 0; i < 1000; i++) { + const rng = new SyntheticRNG(1000 + i); + const frame = generateSyntheticFrame(aircraftDict, Date.now(), config, rng); + frames.push(frame); + } + + const delayErrors = frames.map(f => f.delay[0] - 100.0); + const dopplerErrors = frames.map(f => f.doppler[0] - 50.0); + + const delayMean = delayErrors.reduce((a, b) => a + b, 0) / delayErrors.length; + const dopplerMean = dopplerErrors.reduce((a, b) => a + b, 0) / dopplerErrors.length; + + expect(delayMean).toBeCloseTo(0, 0); + expect(dopplerMean).toBeCloseTo(0, 0); + + const delayStd = Math.sqrt( + delayErrors.reduce((sum, x) => sum + (x - delayMean) ** 2, 0) / delayErrors.length + ); + const dopplerStd = Math.sqrt( + dopplerErrors.reduce((sum, x) => sum + (x - dopplerMean) ** 2, 0) / dopplerErrors.length + ); + + expect(delayStd).toBeCloseTo(config.noise_delay, 0); + expect(dopplerStd).toBeCloseTo(config.noise_doppler, 0); + }); + + test('respects detection probability', () => { + const aircraftDict = { + 'abc123': {delay: 100.0, doppler: 50.0, flight: 'UAL123'}, + 'def456': {delay: 200.0, doppler: -75.0, flight: 'DAL456'}, + 'ghi789': {delay: 150.0, doppler: 25.0, flight: 'AAL789'} + }; + + const config = { + ...DEFAULT_SYNTHETIC_CONFIG, + detection_prob: 0.7, + false_alarm_rate: 0 + }; + + const detectionCounts = []; + for (let i = 0; i < 500; i++) { + const rng = new SyntheticRNG(2000 + i); + const frame = generateSyntheticFrame(aircraftDict, Date.now(), config, rng); + detectionCounts.push(frame.delay.length); + } + + const meanDetections = detectionCounts.reduce((a, b) => a + b, 0) / detectionCounts.length; + const expectedDetections = Object.keys(aircraftDict).length * config.detection_prob; + + expect(meanDetections).toBeCloseTo(expectedDetections, 0); + }); + + test('generates false alarms according to Poisson distribution', () => { + const config = { + ...DEFAULT_SYNTHETIC_CONFIG, + detection_prob: 0, + false_alarm_rate: 3.0 + }; + + const falseCounts = []; + for (let i = 0; i < 500; i++) { + const rng = new SyntheticRNG(3000 + i); + const frame = generateSyntheticFrame({}, Date.now(), config, rng); + falseCounts.push(frame.delay.length); + } + + const meanFalseAlarms = falseCounts.reduce((a, b) => a + b, 0) / falseCounts.length; + expect(meanFalseAlarms).toBeCloseTo(config.false_alarm_rate, 0); + }); + + test('false alarms have null ADS-B data', () => { + const config = { + ...DEFAULT_SYNTHETIC_CONFIG, + detection_prob: 0, + false_alarm_rate: 5.0 + }; + + const rng = new SyntheticRNG(42); + const frame = generateSyntheticFrame({}, Date.now(), config, rng); + + const nullCount = frame.adsb.filter(a => a === null).length; + expect(nullCount).toBe(frame.delay.length); + }); + + test('skips aircraft with missing delay or doppler', () => { + const aircraftDict = { + 'abc123': {delay: 100.0, doppler: 50.0, flight: 'UAL123'}, + 'def456': {doppler: -75.0, flight: 'DAL456'}, + 'ghi789': {delay: 150.0, flight: 'AAL789'} + }; + + const config = {...DEFAULT_SYNTHETIC_CONFIG, detection_prob: 1.0, false_alarm_rate: 0}; + const rng = new SyntheticRNG(42); + const frame = generateSyntheticFrame(aircraftDict, Date.now(), config, rng); + + expect(frame.delay.length).toBe(1); + }); + + test('includes ADS-B metadata for aircraft detections', () => { + const aircraftDict = { + 'abc123': { + delay: 100.0, + doppler: 50.0, + flight: 'UAL123' + } + }; + + const config = {...DEFAULT_SYNTHETIC_CONFIG, detection_prob: 1.0, false_alarm_rate: 0}; + const rng = new SyntheticRNG(42); + const frame = generateSyntheticFrame(aircraftDict, Date.now(), config, rng); + + expect(frame.adsb[0]).toEqual({ + hex: 'abc123', + flight: 'UAL123' + }); + }); + }); + + describe('Frame format conversion', () => { + test('converts aircraft dict to frame format', () => { + const aircraftDict = { + 'abc123': {delay: 100.0, doppler: 50.0, flight: 'UAL123'}, + 'def456': {delay: 200.0, doppler: -75.0, flight: 'DAL456'} + }; + + const aircraftRawData = { + aircraft: [ + {hex: 'abc123', lat: 37.77, lon: -122.42, alt_baro: 5000, gs: 250, track: 45}, + {hex: 'def456', lat: 37.81, lon: -122.34, alt_baro: 8500, gs: 300, track: 120} + ] + }; + + const timestamp = Date.now(); + const frame = convertToFrameFormat(aircraftDict, aircraftRawData, timestamp); + + expect(frame.timestamp).toBe(timestamp); + expect(frame.delay).toEqual([100.0, 200.0]); + expect(frame.doppler).toEqual([50.0, -75.0]); + expect(frame.snr).toEqual([15.0, 15.0]); + expect(frame.adsb.length).toBe(2); + expect(frame.adsb[0].hex).toBe('abc123'); + expect(frame.adsb[0].lat).toBe(37.77); + expect(frame.adsb[1].hex).toBe('def456'); + }); + + test('handles missing raw data gracefully', () => { + const aircraftDict = { + 'abc123': {delay: 100.0, doppler: 50.0, flight: 'UAL123'} + }; + + const timestamp = Date.now(); + const frame = convertToFrameFormat(aircraftDict, null, timestamp); + + expect(frame.adsb[0]).toEqual({ + hex: 'abc123', + flight: 'UAL123' + }); + }); + + test('uses alt_geom fallback when alt_baro missing', () => { + const aircraftDict = { + 'abc123': {delay: 100.0, doppler: 50.0, flight: 'UAL123'} + }; + + const aircraftRawData = { + aircraft: [ + {hex: 'abc123', lat: 37.77, lon: -122.42, alt_geom: 5100, gs: 250, track: 45} + ] + }; + + const timestamp = Date.now(); + const frame = convertToFrameFormat(aircraftDict, aircraftRawData, timestamp); + + expect(frame.adsb[0].alt_baro).toBe(5100); + }); + + test('skips aircraft with missing delay or doppler', () => { + const aircraftDict = { + 'abc123': {delay: 100.0, doppler: 50.0, flight: 'UAL123'}, + 'def456': {doppler: -75.0, flight: 'DAL456'} + }; + + const aircraftRawData = { + aircraft: [ + {hex: 'abc123', lat: 37.77, lon: -122.42, alt_baro: 5000, gs: 250, track: 45}, + {hex: 'def456', lat: 37.81, lon: -122.34, alt_baro: 8500, gs: 300, track: 120} + ] + }; + + const timestamp = Date.now(); + const frame = convertToFrameFormat(aircraftDict, aircraftRawData, timestamp); + + expect(frame.delay.length).toBe(1); + expect(frame.adsb[0].hex).toBe('abc123'); + }); + }); +}); From 4e97d8c495a83388c1b60766e2c1cb30e96daa63 Mon Sep 17 00:00:00 2001 From: Jonny Spicer Date: Fri, 19 Dec 2025 10:08:11 +0000 Subject: [PATCH 2/2] Address critical code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security & DoS Protection: - Add MAX_FRAMES (1000) and MAX_DURATION_SECONDS (300) limits to prevent DoS attacks - Implement comprehensive SSRF protection matching /api/dd endpoint - Validate against private IP ranges (127.x, 10.x, 172.16-31.x, 192.168.x, 169.254.x) - Check IPv6 private ranges (::1, fe80, fc00, fd00, ff00) - Resolve hostnames and validate resolved IPs aren't private - Prevent invalid IP formats (hex, octal, decimal encoded) Error Handling: - Add try-catch around delay-Doppler computation to prevent endpoint crashes - Log errors for individual aircraft failures while continuing processing Input Validation: - Fix NaN validation in parseSyntheticConfig with parseAndValidate helper - Validate parsed numbers aren't NaN before applying to config - Add frame count validation against MAX_FRAMES limit - Add duration validation against MAX_DURATION_SECONDS limit Docker Configuration: - Revert docker-compose.yml to network_mode: host - Port mapping should only be used for local testing All 110 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- docker-compose.yml | 4 +- src/node/synthetic.js | 52 +++++++++++++---- src/server.js | 133 +++++++++++++++++++++++++++++++++--------- 3 files changed, 149 insertions(+), 40 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ddb35ef..068bbf5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: restart: always build: context: . + network: host image: adsb2dd + network_mode: host container_name: adsb2dd - ports: - - "49155:49155" diff --git a/src/node/synthetic.js b/src/node/synthetic.js index 0926558..8a82977 100644 --- a/src/node/synthetic.js +++ b/src/node/synthetic.js @@ -67,47 +67,67 @@ export const DEFAULT_SYNTHETIC_CONFIG = { doppler_max: 200 // Max Doppler for false alarms (Hz) }; +export const MAX_FRAMES = 1000; +export const MAX_DURATION_SECONDS = 300; + /// @brief Parse synthetic configuration from query parameters /// @param query Express query object /// @return Configuration object with defaults applied export function parseSyntheticConfig(query) { const config = { ...DEFAULT_SYNTHETIC_CONFIG }; + const parseAndValidate = (value, type = 'float') => { + const parsed = type === 'int' ? parseInt(value) : parseFloat(value); + return isNaN(parsed) ? null : parsed; + }; + if (query.noise_delay !== undefined) { - config.noise_delay = parseFloat(query.noise_delay); + const val = parseAndValidate(query.noise_delay); + if (val !== null) config.noise_delay = val; } if (query.noise_doppler !== undefined) { - config.noise_doppler = parseFloat(query.noise_doppler); + const val = parseAndValidate(query.noise_doppler); + if (val !== null) config.noise_doppler = val; } if (query.snr_min !== undefined) { - config.snr_min = parseFloat(query.snr_min); + const val = parseAndValidate(query.snr_min); + if (val !== null) config.snr_min = val; } if (query.snr_max !== undefined) { - config.snr_max = parseFloat(query.snr_max); + const val = parseAndValidate(query.snr_max); + if (val !== null) config.snr_max = val; } if (query.detection_prob !== undefined) { - config.detection_prob = parseFloat(query.detection_prob); + const val = parseAndValidate(query.detection_prob); + if (val !== null) config.detection_prob = val; } if (query.false_alarm_rate !== undefined) { - config.false_alarm_rate = parseFloat(query.false_alarm_rate); + const val = parseAndValidate(query.false_alarm_rate); + if (val !== null) config.false_alarm_rate = val; } if (query.frame_interval !== undefined) { - config.frame_interval = parseInt(query.frame_interval); + const val = parseAndValidate(query.frame_interval, 'int'); + if (val !== null) config.frame_interval = val; } if (query.duration !== undefined) { - config.duration = parseFloat(query.duration); + const val = parseAndValidate(query.duration); + if (val !== null) config.duration = val; } if (query.delay_min !== undefined) { - config.delay_min = parseFloat(query.delay_min); + const val = parseAndValidate(query.delay_min); + if (val !== null) config.delay_min = val; } if (query.delay_max !== undefined) { - config.delay_max = parseFloat(query.delay_max); + const val = parseAndValidate(query.delay_max); + if (val !== null) config.delay_max = val; } if (query.doppler_min !== undefined) { - config.doppler_min = parseFloat(query.doppler_min); + const val = parseAndValidate(query.doppler_min); + if (val !== null) config.doppler_min = val; } if (query.doppler_max !== undefined) { - config.doppler_max = parseFloat(query.doppler_max); + const val = parseAndValidate(query.doppler_max); + if (val !== null) config.doppler_max = val; } if (query.seed !== undefined) { config.seed = query.seed; @@ -143,6 +163,9 @@ export function validateSyntheticConfig(config) { if (config.duration <= 0) { errors.push('duration must be positive'); } + if (config.duration > MAX_DURATION_SECONDS) { + errors.push(`duration must be <= ${MAX_DURATION_SECONDS} seconds`); + } if (config.delay_min >= config.delay_max) { errors.push('delay_min must be < delay_max'); } @@ -150,6 +173,11 @@ export function validateSyntheticConfig(config) { errors.push('doppler_min must be < doppler_max'); } + const nFrames = Math.ceil((config.duration * 1000) / config.frame_interval); + if (nFrames > MAX_FRAMES) { + errors.push(`Requested ${nFrames} frames exceeds maximum of ${MAX_FRAMES}`); + } + return { valid: errors.length === 0, errors: errors diff --git a/src/server.js b/src/server.js index 57d9aba..7375803 100644 --- a/src/server.js +++ b/src/server.js @@ -258,7 +258,7 @@ app.get('/api/synthetic-detections', async (req, res) => { const [rxLat, rxLon, rxAlt] = rxParams; const [txLat, txLon, txAlt] = txParams; - // Validate server URL (reuse logic from /api/dd) + // Validate server URL (same as /api/dd) let serverUrl; try { serverUrl = new URL(server); @@ -274,6 +274,82 @@ app.get('/api/synthetic-detections', async (req, res) => { const isAdsbLol = serverUrl.hostname === 'api.adsb.lol'; + if (isAdsbLol) { + if (server !== 'https://api.adsb.lol' || serverUrl.protocol !== 'https:') { + return res.status(400).json({ error: 'Invalid adsb.lol URL' }); + } + } + + if (!isAdsbLol) { + const hostname = serverUrl.hostname; + + const privateIPv4Ranges = [ + /^127\./, + /^10\./, + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, + /^192\.168\./, + /^169\.254\./, + /^0\.0\.0\.0$/, + /localhost/i + ]; + + const privateIPv6Ranges = [ + /^::1$/, + /^::$/, + /^fe80:/i, + /^fc00:/i, + /^fd00:/i, + /^ff00:/i, + ]; + + if (/^::ffff:/i.test(hostname)) { + const ipv4Part = hostname.replace(/^::ffff:/i, ''); + if (privateIPv4Ranges.some(range => range.test(ipv4Part))) { + return res.status(400).json({ error: 'Server URL points to private network' }); + } + } + + if (privateIPv4Ranges.some(range => range.test(hostname))) { + return res.status(400).json({ error: 'Server URL points to private network' }); + } + + if (privateIPv6Ranges.some(range => range.test(hostname))) { + return res.status(400).json({ error: 'Server URL points to private network' }); + } + + if (/^(0x[0-9a-f]+|\d+|0[0-7]+)$/i.test(hostname)) { + return res.status(400).json({ error: 'Server URL uses invalid IP format' }); + } + + if (!/^[\d.:]+$/.test(hostname)) { + try { + const resolutions = await Promise.allSettled([ + resolve4(hostname), + resolve6(hostname) + ]); + + const resolvedIPs = []; + for (const result of resolutions) { + if (result.status === 'fulfilled' && Array.isArray(result.value)) { + resolvedIPs.push(...result.value); + } + } + + if (resolvedIPs.length === 0) { + return res.status(400).json({ error: 'Unable to resolve server hostname' }); + } + + for (const ip of resolvedIPs) { + if (isPrivateIP(ip)) { + return res.status(400).json({ error: 'Server hostname resolves to private network' }); + } + } + } catch (error) { + return res.status(400).json({ error: 'Unable to resolve server hostname' }); + } + } + } + // Initialize RNG const rng = new SyntheticRNG(syntheticConfig.seed); @@ -326,31 +402,36 @@ app.get('/api/synthetic-detections', async (req, res) => { continue; } - const hexCode = aircraft.hex; - const tar = lla2ecef(aircraft['lat'], aircraft['lon'], - ft2m(aircraft['alt_geom'])); - - const dRxTar = norm([ecefRx.x - tar.x, ecefRx.y - tar.y, - ecefRx.z - tar.z]); - const dTxTar = norm([ecefTx.x - tar.x, ecefTx.y - tar.y, - ecefTx.z - tar.z]); - const delay = (dRxTar + dTxTar - dRxTx) / 1000; // Convert to km - - const doppler = calculateDopplerFromVelocity( - aircraft, tar, ecefRx, ecefTx, dRxTar, dTxTar, fc - ); - - if (doppler !== null) { - aircraftDict[hexCode] = { - delay: delay, - doppler: doppler, - flight: aircraft.flight, - lat: aircraft.lat, - lon: aircraft.lon, - alt_baro: aircraft.alt_baro || aircraft.alt_geom, - gs: aircraft.gs, - track: aircraft.track - }; + try { + const hexCode = aircraft.hex; + const tar = lla2ecef(aircraft['lat'], aircraft['lon'], + ft2m(aircraft['alt_geom'])); + + const dRxTar = norm([ecefRx.x - tar.x, ecefRx.y - tar.y, + ecefRx.z - tar.z]); + const dTxTar = norm([ecefTx.x - tar.x, ecefTx.y - tar.y, + ecefTx.z - tar.z]); + const delay = (dRxTar + dTxTar - dRxTx) / 1000; + + const doppler = calculateDopplerFromVelocity( + aircraft, tar, ecefRx, ecefTx, dRxTar, dTxTar, fc + ); + + if (doppler !== null) { + aircraftDict[hexCode] = { + delay: delay, + doppler: doppler, + flight: aircraft.flight, + lat: aircraft.lat, + lon: aircraft.lon, + alt_baro: aircraft.alt_baro || aircraft.alt_geom, + gs: aircraft.gs, + track: aircraft.track + }; + } + } catch (err) { + console.error(`Error computing delay-Doppler for aircraft ${aircraft.hex}:`, err.message); + continue; } }