diff --git a/README.md b/README.md index 238b893..dd36527 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,82 @@ The synthetic detections have the following statistical properties: - **False alarms**: Poisson-distributed count with rate `false_alarm_rate` per frame - **False alarm positions**: Uniformly distributed in delay-Doppler space +## Mach 5 Anomalous Target Generation + +For testing anomaly detection systems, adsb2dd can generate synthetic Mach 5 (~1715 m/s) target trajectories with realistic delay-Doppler characteristics. + +### Usage + +Generate Mach 5 target data using the provided script: + +```bash +node generate_mach5_targets.js [options] +``` + +### Options + +- `--rx LAT,LON,ALT`: Receiver position (default: 37.7644,-122.3954,23) +- `--tx LAT,LON,ALT`: Transmitter position (default: 37.49917,-121.87222,783) +- `--fc FREQUENCY`: Carrier frequency in MHz (default: 503) +- `--start LAT,LON,ALT`: Starting position for Mach 5 target (default: 37.5,-123.0,15000) +- `--heading DEGREES`: Direction of travel (default: 90, eastward) +- `--duration SECONDS`: Duration of trajectory (default: 60) +- `--timestep SECONDS`: Time between position samples (default: 0.5) +- `--output FILE`: Output .detection file (default: ./data/mach5_targets.detection) +- `--no-noise`: Disable synthetic noise (perfect measurements) + +### Examples + +**Generate default Mach 5 target:** +```bash +node generate_mach5_targets.js +``` + +**Custom trajectory heading north from San Francisco:** +```bash +node generate_mach5_targets.js --start 37.7,-122.4,15000 --heading 0 --duration 120 +``` + +**Perfect measurements (no noise):** +```bash +node generate_mach5_targets.js --no-noise --output data/mach5_perfect.detection +``` + +### Output Format + +The generated `.detection` file contains an array of frames compatible with [retina-tracker](https://github.com/30hours/retina-tracker): + +```json +[ + { + "timestamp": 1718747745000, + "delay": [156.32], + "doppler": [523.45], + "snr": [15.2], + "adsb": [ + { + "hex": "MACH5X", + "lat": 37.52, + "lon": -122.95, + "alt_baro": 49213, + "gs": 3332, + "track": 90, + "flight": "MACH5" + } + ] + } +] +``` + +### Technical Details + +- **Speed**: Mach 5 at sea level (~1715 m/s or ~3332 knots) +- **Doppler shift**: Significantly higher than normal aircraft (typically >100 Hz) +- **Trajectory**: Great circle path with configurable heading +- **Noise**: Optional Gaussian noise on delay and Doppler measurements + +This feature is designed to test anomaly detection in retina-tracker (see [retina-tracker#4](https://github.com/offworldlabs/retina-tracker/issues/4)). + ## Future Work - Add a 2D plot showing all aircraft in delay-Doppler space. diff --git a/generate_mach5_targets.js b/generate_mach5_targets.js new file mode 100755 index 0000000..981cd8d --- /dev/null +++ b/generate_mach5_targets.js @@ -0,0 +1,207 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import { generateMach5Trajectory, trajectoryToDelayDoppler, SyntheticRNG } from './src/node/synthetic.js'; + +const DEFAULT_CONFIG = { + rx: { + lat: 37.7644, + lon: -122.3954, + alt: 23 + }, + tx: { + lat: 37.49917, + lon: -121.87222, + alt: 783 + }, + fc: 503, + targets: [ + { + startLat: 37.5, + startLon: -123.0, + startAlt: 15000, + heading: 90, + duration: 60, + timestep: 0.5 + } + ], + output: './data/mach5_targets.detection', + addNoise: true, + noiseConfig: { + delay: 0.5, + doppler: 2.0, + snr_min: 8, + snr_max: 20, + seed: 'mach5-test' + } +}; + +function parseArgs() { + const args = process.argv.slice(2); + const config = { ...DEFAULT_CONFIG }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--rx': + const rx = args[++i].split(',').map(parseFloat); + config.rx = { lat: rx[0], lon: rx[1], alt: rx[2] }; + break; + case '--tx': + const tx = args[++i].split(',').map(parseFloat); + config.tx = { lat: tx[0], lon: tx[1], alt: tx[2] }; + break; + case '--fc': + config.fc = parseFloat(args[++i]); + break; + case '--start': + const start = args[++i].split(',').map(parseFloat); + config.targets[0].startLat = start[0]; + config.targets[0].startLon = start[1]; + config.targets[0].startAlt = start[2]; + break; + case '--heading': + config.targets[0].heading = parseFloat(args[++i]); + break; + case '--duration': + config.targets[0].duration = parseFloat(args[++i]); + break; + case '--timestep': + config.targets[0].timestep = parseFloat(args[++i]); + break; + case '--output': + config.output = args[++i]; + break; + case '--no-noise': + config.addNoise = false; + break; + case '--help': + printUsage(); + process.exit(0); + } + } + + return config; +} + +function printUsage() { + console.log(` +Usage: node generate_mach5_targets.js [options] + +Generate synthetic Mach 5 target data for testing anomaly detection systems. + +Options: + --rx LAT,LON,ALT Receiver position (default: 37.7644,-122.3954,23) + --tx LAT,LON,ALT Transmitter position (default: 37.49917,-121.87222,783) + --fc FREQUENCY Carrier frequency in MHz (default: 503) + --start LAT,LON,ALT Starting position for Mach 5 target (default: 37.5,-123.0,15000) + --heading DEGREES Direction of travel (default: 90, eastward) + --duration SECONDS Duration of trajectory (default: 60) + --timestep SECONDS Time between position samples (default: 0.5) + --output FILE Output .detection file (default: ./data/mach5_targets.detection) + --no-noise Disable synthetic noise (perfect measurements) + --help Show this help message + +Examples: + # Generate default Mach 5 target + node generate_mach5_targets.js + + # Custom trajectory heading north from SF + node generate_mach5_targets.js --start 37.7,-122.4,15000 --heading 0 --duration 120 + + # Perfect measurements (no noise) + node generate_mach5_targets.js --no-noise --output data/mach5_perfect.detection +`); +} + +function main() { + const config = parseArgs(); + + console.log('Generating Mach 5 anomalous targets...\n'); + console.log('Configuration:'); + console.log(` RX: ${config.rx.lat}, ${config.rx.lon}, ${config.rx.alt}m`); + console.log(` TX: ${config.tx.lat}, ${config.tx.lon}, ${config.tx.alt}m`); + console.log(` Frequency: ${config.fc} MHz`); + console.log(` Output: ${config.output}`); + console.log(` Noise: ${config.addNoise ? 'enabled' : 'disabled'}\n`); + + const outputDir = path.dirname(config.output); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const allFrames = []; + + for (const targetConfig of config.targets) { + console.log(`Generating trajectory:`); + console.log(` Start: ${targetConfig.startLat}, ${targetConfig.startLon}, ${targetConfig.startAlt}m`); + console.log(` Heading: ${targetConfig.heading}°`); + console.log(` Duration: ${targetConfig.duration}s`); + console.log(` Timestep: ${targetConfig.timestep}s`); + + const trajectory = generateMach5Trajectory( + targetConfig.startLat, + targetConfig.startLon, + targetConfig.startAlt, + targetConfig.heading, + targetConfig.duration, + targetConfig.timestep + ); + + console.log(` Generated ${trajectory.length} positions`); + + const detections = trajectoryToDelayDoppler( + trajectory, + config.rx.lat, + config.rx.lon, + config.rx.alt, + config.tx.lat, + config.tx.lon, + config.tx.alt, + config.fc + ); + + if (config.addNoise) { + const rng = new SyntheticRNG(config.noiseConfig.seed); + for (const detection of detections) { + detection.delay += rng.gaussian(0, config.noiseConfig.delay); + detection.doppler += rng.gaussian(0, config.noiseConfig.doppler); + detection.snr = rng.uniform(config.noiseConfig.snr_min, config.noiseConfig.snr_max); + } + } + + for (const detection of detections) { + allFrames.push({ + timestamp: detection.timestamp, + delay: [detection.delay], + doppler: [detection.doppler], + snr: [detection.snr || 15.0], + adsb: [detection.adsb] + }); + } + } + + fs.writeFileSync(config.output, JSON.stringify(allFrames, null, 2)); + console.log(`\nWrote ${allFrames.length} frames to ${config.output}`); + + const firstFrame = allFrames[0]; + const lastFrame = allFrames[allFrames.length - 1]; + console.log(`\nSummary:`); + console.log(` First detection:`); + console.log(` Delay: ${firstFrame.delay[0].toFixed(2)} km`); + console.log(` Doppler: ${firstFrame.doppler[0].toFixed(2)} Hz`); + console.log(` Last detection:`); + console.log(` Delay: ${lastFrame.delay[0].toFixed(2)} km`); + console.log(` Doppler: ${lastFrame.doppler[0].toFixed(2)} Hz`); + + const delayRange = Math.max(...allFrames.map(f => f.delay[0])) - Math.min(...allFrames.map(f => f.delay[0])); + const dopplerRange = Math.max(...allFrames.map(f => f.doppler[0])) - Math.min(...allFrames.map(f => f.doppler[0])); + console.log(`\nRange covered:`); + console.log(` Delay: ${delayRange.toFixed(2)} km`); + console.log(` Doppler: ${dopplerRange.toFixed(2)} Hz`); + + console.log(`\nMach 5 speed: ~1715 m/s`); + console.log(`Ground speed: ~${(1715 * 1.94384).toFixed(0)} knots`); +} + +main(); diff --git a/src/node/synthetic.js b/src/node/synthetic.js index 8a82977..1503c6e 100644 --- a/src/node/synthetic.js +++ b/src/node/synthetic.js @@ -339,3 +339,154 @@ export function convertToFrameFormat(aircraftDict, aircraftRawData, timestamp) { adsb: adsb }; } + +/// @brief Generate synthetic Mach 5 target trajectory +/// @param startLat Starting latitude (degrees) +/// @param startLon Starting longitude (degrees) +/// @param startAlt Starting altitude (meters) +/// @param heading Direction of travel (degrees, 0=North, 90=East) +/// @param duration Duration of trajectory (seconds) +/// @param timestep Time between positions (seconds) +/// @return Array of positions with {lat, lon, alt, timestamp, speed, heading} +export function generateMach5Trajectory(startLat, startLon, startAlt, heading, duration, timestep = 1.0) { + const MACH_5_SPEED = 1715; + const EARTH_RADIUS = 6371000; + + const positions = []; + const numSteps = Math.ceil(duration / timestep); + const startTime = Date.now(); + + for (let i = 0; i < numSteps; i++) { + const elapsedTime = i * timestep; + const distanceTraveled = MACH_5_SPEED * elapsedTime; + + const headingRad = (heading * Math.PI) / 180; + const angularDistance = distanceTraveled / EARTH_RADIUS; + + const lat1 = (startLat * Math.PI) / 180; + const lon1 = (startLon * Math.PI) / 180; + + const lat2 = Math.asin( + Math.sin(lat1) * Math.cos(angularDistance) + + Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(headingRad) + ); + + const lon2 = lon1 + Math.atan2( + Math.sin(headingRad) * Math.sin(angularDistance) * Math.cos(lat1), + Math.cos(angularDistance) - Math.sin(lat1) * Math.sin(lat2) + ); + + positions.push({ + lat: (lat2 * 180) / Math.PI, + lon: (lon2 * 180) / Math.PI, + alt: startAlt, + timestamp: startTime + elapsedTime * 1000, + speed: MACH_5_SPEED, + heading: heading, + hex: 'MACH5X', + flight: 'MACH5', + alt_baro: startAlt / 0.3048, + gs: MACH_5_SPEED * 1.94384, + track: heading + }); + } + + return positions; +} + +/// @brief Generate delay-Doppler data for Mach 5 trajectory +/// @param trajectory Array of positions from generateMach5Trajectory +/// @param rxLat Receiver latitude (degrees) +/// @param rxLon Receiver longitude (degrees) +/// @param rxAlt Receiver altitude (meters) +/// @param txLat Transmitter latitude (degrees) +/// @param txLon Transmitter longitude (degrees) +/// @param txAlt Transmitter altitude (meters) +/// @param fc Carrier frequency (MHz) +/// @return Array of {timestamp, delay, doppler, adsb} +export function trajectoryToDelayDoppler(trajectory, rxLat, rxLon, rxAlt, txLat, txLon, txAlt, fc) { + const lla2ecef = (lat, lon, alt) => { + const radian = Math.PI / 180.0; + const a = 6378137.0; + const f = 1.0 / 298.257223563; + const esq = 2.0 * f - f * f; + const latRad = lat * radian; + const lonRad = lon * radian; + const N = a / Math.sqrt(1.0 - esq * Math.sin(latRad) * Math.sin(latRad)); + return { + x: (N + alt) * Math.cos(latRad) * Math.cos(lonRad), + y: (N + alt) * Math.cos(latRad) * Math.sin(lonRad), + z: (N * (1.0 - esq) + alt) * Math.sin(latRad) + }; + }; + + const norm = (vec) => Math.sqrt(vec.x ** 2 + vec.y ** 2 + vec.z ** 2); + + const ecefRx = lla2ecef(rxLat, rxLon, rxAlt); + const ecefTx = lla2ecef(txLat, txLon, txAlt); + const dRxTx = norm({ + x: ecefRx.x - ecefTx.x, + y: ecefRx.y - ecefTx.y, + z: ecefRx.z - ecefTx.z + }); + + const detections = []; + + for (let i = 0; i < trajectory.length; i++) { + const pos = trajectory[i]; + const tar = lla2ecef(pos.lat, pos.lon, pos.alt); + + 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 + }); + + const delay = (dRxTar + dTxTar - dRxTx) / 1000; + + let doppler = 0; + if (i > 0) { + const prevPos = trajectory[i - 1]; + const dt = (pos.timestamp - prevPos.timestamp) / 1000; + const prevTar = lla2ecef(prevPos.lat, prevPos.lon, prevPos.alt); + const prevDRxTar = norm({ + x: ecefRx.x - prevTar.x, + y: ecefRx.y - prevTar.y, + z: ecefRx.z - prevTar.z + }); + const prevDTxTar = norm({ + x: ecefTx.x - prevTar.x, + y: ecefTx.y - prevTar.y, + z: ecefTx.z - prevTar.z + }); + const prevBistatic = prevDRxTar + prevDTxTar; + const currBistatic = dRxTar + dTxTar; + const rangeRate = (currBistatic - prevBistatic) / dt; + const wavelength = 299792458 / (fc * 1e6); + doppler = -rangeRate / wavelength; + } + + detections.push({ + timestamp: pos.timestamp, + delay: delay, + doppler: doppler, + adsb: { + hex: pos.hex, + lat: pos.lat, + lon: pos.lon, + alt_baro: pos.alt_baro, + gs: pos.gs, + track: pos.track, + flight: pos.flight + } + }); + } + + return detections; +} diff --git a/test/synthetic.test.js b/test/synthetic.test.js index f8e7c36..94a93f4 100644 --- a/test/synthetic.test.js +++ b/test/synthetic.test.js @@ -4,7 +4,9 @@ import { parseSyntheticConfig, validateSyntheticConfig, generateSyntheticFrame, - convertToFrameFormat + convertToFrameFormat, + generateMach5Trajectory, + trajectoryToDelayDoppler } from '../src/node/synthetic.js'; describe('Synthetic Detection Generation', () => { @@ -474,3 +476,136 @@ describe('Synthetic Detection Generation', () => { }); }); }); + +describe('Mach 5 Trajectory Generation', () => { + test('generateMach5Trajectory produces correct number of positions', () => { + const trajectory = generateMach5Trajectory( + 37.7, -122.4, 15000, 90, 60, 1.0 + ); + + expect(trajectory.length).toBe(60); + }); + + test('generateMach5Trajectory has consistent speed', () => { + const trajectory = generateMach5Trajectory( + 37.7, -122.4, 15000, 90, 10, 1.0 + ); + + for (const pos of trajectory) { + expect(pos.speed).toBe(1715); + expect(pos.gs).toBeCloseTo(1715 * 1.94384, 1); + } + }); + + test('generateMach5Trajectory moves in correct direction', () => { + const trajectory = generateMach5Trajectory( + 37.7, -122.4, 15000, 90, 10, 1.0 + ); + + const startLon = trajectory[0].lon; + const endLon = trajectory[trajectory.length - 1].lon; + + expect(endLon).toBeGreaterThan(startLon); + }); + + test('generateMach5Trajectory maintains altitude', () => { + const altitude = 15000; + const trajectory = generateMach5Trajectory( + 37.7, -122.4, altitude, 90, 10, 1.0 + ); + + for (const pos of trajectory) { + expect(pos.alt).toBe(altitude); + } + }); + + test('generateMach5Trajectory has correct ADS-B fields', () => { + const trajectory = generateMach5Trajectory( + 37.7, -122.4, 15000, 90, 10, 1.0 + ); + + for (const pos of trajectory) { + expect(pos.hex).toBe('MACH5X'); + expect(pos.flight).toBe('MACH5'); + expect(pos.track).toBe(90); + expect(pos).toHaveProperty('alt_baro'); + expect(pos).toHaveProperty('timestamp'); + } + }); + + test('trajectoryToDelayDoppler produces detections', () => { + const trajectory = generateMach5Trajectory( + 37.7, -122.4, 15000, 90, 10, 1.0 + ); + + const detections = trajectoryToDelayDoppler( + trajectory, 37.7644, -122.3954, 23, 37.49917, -121.87222, 783, 503 + ); + + expect(detections.length).toBe(trajectory.length); + }); + + test('trajectoryToDelayDoppler computes delay correctly', () => { + const trajectory = generateMach5Trajectory( + 37.7, -122.4, 15000, 90, 10, 1.0 + ); + + const detections = trajectoryToDelayDoppler( + trajectory, 37.7644, -122.3954, 23, 37.49917, -121.87222, 783, 503 + ); + + for (const detection of detections) { + expect(detection.delay).toBeGreaterThan(0); + expect(detection.delay).toBeLessThan(1000); + } + }); + + test('trajectoryToDelayDoppler computes Doppler for high-speed target', () => { + const trajectory = generateMach5Trajectory( + 37.7, -122.4, 15000, 90, 10, 1.0 + ); + + const detections = trajectoryToDelayDoppler( + trajectory, 37.7644, -122.3954, 23, 37.49917, -121.87222, 783, 503 + ); + + const dopplerValues = detections.slice(1).map(d => Math.abs(d.doppler)); + const maxDoppler = Math.max(...dopplerValues); + + expect(maxDoppler).toBeGreaterThan(100); + }); + + test('trajectoryToDelayDoppler includes ADS-B metadata', () => { + const trajectory = generateMach5Trajectory( + 37.7, -122.4, 15000, 90, 10, 1.0 + ); + + const detections = trajectoryToDelayDoppler( + trajectory, 37.7644, -122.3954, 23, 37.49917, -121.87222, 783, 503 + ); + + for (const detection of detections) { + expect(detection.adsb).toHaveProperty('hex'); + expect(detection.adsb).toHaveProperty('lat'); + expect(detection.adsb).toHaveProperty('lon'); + expect(detection.adsb).toHaveProperty('alt_baro'); + expect(detection.adsb).toHaveProperty('gs'); + expect(detection.adsb).toHaveProperty('track'); + expect(detection.adsb).toHaveProperty('flight'); + } + }); + + test('Mach 5 trajectory covers significant distance', () => { + const trajectory = generateMach5Trajectory( + 37.7, -122.4, 15000, 90, 60, 1.0 + ); + + const start = trajectory[0]; + const end = trajectory[trajectory.length - 1]; + + const latDiff = Math.abs(end.lat - start.lat); + const lonDiff = Math.abs(end.lon - start.lon); + + expect(latDiff + lonDiff).toBeGreaterThan(0.5); + }); +});