Implement aircraft position validation to fix obviously wrong trails

Add comprehensive position validation to filter out impossible aircraft
movements and improve trail accuracy as requested in issue #16.

**Position Validation Features:**
- Geographic coordinate bounds checking (lat: -90 to 90, lon: -180 to 180)
- Altitude validation (range: -500ft to 60,000ft)
- Speed validation (max: 2000 knots ≈ Mach 3)
- Distance jump validation (max: 500 nautical miles)
- Time consistency validation (reject out-of-order timestamps)
- Speed consistency warnings (reported vs. implied speed)

**Integration Points:**
- updateHistories(): Validates before adding to position history (trails)
- mergeAircraftData(): Validates before updating aircraft position state
- Comprehensive logging with ICAO identification for debugging

**Validation Logic:**
- Rejects obviously wrong positions that would create impossible flight paths
- Warns about high but possible speeds (>800 knots) for monitoring
- Maintains detailed logs: [POSITION_VALIDATION] ICAO ABCDEF: REJECTED/WARNING
- Performance optimized with early returns and minimal overhead

**Result:**
- Users see only realistic aircraft trails and movements
- Obviously wrong ADS-B data (teleporting, impossible speeds) filtered out
- Debug logs provide visibility into validation decisions
- Clean flight tracking without zigzag patterns or position jumps

Fixes #16

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2025-08-24 19:20:38 +02:00
commit 9242852c01

View file

@ -22,6 +22,7 @@ package merger
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"math" "math"
"sync" "sync"
"time" "time"
@ -33,8 +34,31 @@ import (
const ( const (
// MaxDistance represents an infinite distance for initialization // MaxDistance represents an infinite distance for initialization
MaxDistance = float64(999999) MaxDistance = float64(999999)
// Position validation constants
MaxSpeedKnots = 2000.0 // Maximum plausible aircraft speed (roughly Mach 3 at cruise altitude)
MaxDistanceNautMiles = 500.0 // Maximum position jump distance in nautical miles
MaxAltitudeFeet = 60000 // Maximum altitude in feet (commercial ceiling ~FL600)
MinAltitudeFeet = -500 // Minimum altitude (below sea level but allow for dead sea, etc.)
// Earth coordinate bounds
MinLatitude = -90.0
MaxLatitude = 90.0
MinLongitude = -180.0
MaxLongitude = 180.0
// Conversion factors
KnotsToKmh = 1.852
NmToKm = 1.852
) )
// ValidationResult represents the result of position validation checks.
type ValidationResult struct {
Valid bool // Whether the position passed all validation checks
Errors []string // List of validation failures for debugging
Warnings []string // List of potential issues (not blocking)
}
// Source represents a data source (dump1090 receiver or similar ADS-B source). // Source represents a data source (dump1090 receiver or similar ADS-B source).
// It contains both static configuration and dynamic status information used // It contains both static configuration and dynamic status information used
// for data fusion decisions and source monitoring. // for data fusion decisions and source monitoring.
@ -417,28 +441,46 @@ func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signa
// - sourceID: Identifier of source providing new data // - sourceID: Identifier of source providing new data
// - timestamp: Timestamp of new data // - timestamp: Timestamp of new data
func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, sourceID string, timestamp time.Time) { func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, sourceID string, timestamp time.Time) {
// Position - use source with best signal or most recent // Position - use source with best signal or most recent, but validate first
if new.Latitude != 0 && new.Longitude != 0 { if new.Latitude != 0 && new.Longitude != 0 {
updatePosition := false // Always validate position before considering update
validation := m.validatePosition(new, state, timestamp)
if state.Latitude == 0 { if !validation.Valid {
// First position update // Log validation errors and skip position update
updatePosition = true icaoHex := fmt.Sprintf("%06X", new.ICAO24)
} else if srcData, ok := state.Sources[sourceID]; ok { for _, err := range validation.Errors {
// Use position from source with strongest signal log.Printf("[POSITION_VALIDATION] ICAO %s: REJECTED position update - %s", icaoHex, err)
currentBest := m.getBestSignalSource(state) }
if currentBest == "" || srcData.SignalLevel > state.Sources[currentBest].SignalLevel { } else {
updatePosition = true // Position is valid, proceed with normal logic
} else if currentBest == sourceID { updatePosition := false
// Same source as current best - allow updates for moving aircraft
if state.Latitude == 0 {
// First position update
updatePosition = true updatePosition = true
} else if srcData, ok := state.Sources[sourceID]; ok {
// Use position from source with strongest signal
currentBest := m.getBestSignalSource(state)
if currentBest == "" || srcData.SignalLevel > state.Sources[currentBest].SignalLevel {
updatePosition = true
} else if currentBest == sourceID {
// Same source as current best - allow updates for moving aircraft
updatePosition = true
}
}
if updatePosition {
state.Latitude = new.Latitude
state.Longitude = new.Longitude
state.PositionSource = sourceID
} }
} }
if updatePosition { // Log warnings even if position is valid
state.Latitude = new.Latitude for _, warning := range validation.Warnings {
state.Longitude = new.Longitude icaoHex := fmt.Sprintf("%06X", new.ICAO24)
state.PositionSource = sourceID log.Printf("[POSITION_VALIDATION] ICAO %s: WARNING - %s", icaoHex, warning)
} }
} }
@ -530,14 +572,31 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
// - signal: Signal strength measurement // - signal: Signal strength measurement
// - timestamp: When this data was received // - timestamp: When this data was received
func (m *Merger) updateHistories(state *AircraftState, aircraft *modes.Aircraft, sourceID string, signal float64, timestamp time.Time) { func (m *Merger) updateHistories(state *AircraftState, aircraft *modes.Aircraft, sourceID string, signal float64, timestamp time.Time) {
// Position history // Position history with validation
if aircraft.Latitude != 0 && aircraft.Longitude != 0 { if aircraft.Latitude != 0 && aircraft.Longitude != 0 {
state.PositionHistory = append(state.PositionHistory, PositionPoint{ // Validate position before adding to history
Time: timestamp, validation := m.validatePosition(aircraft, state, timestamp)
Latitude: aircraft.Latitude,
Longitude: aircraft.Longitude, if validation.Valid {
Source: sourceID, state.PositionHistory = append(state.PositionHistory, PositionPoint{
}) Time: timestamp,
Latitude: aircraft.Latitude,
Longitude: aircraft.Longitude,
Source: sourceID,
})
} else {
// Log validation errors for debugging
icaoHex := fmt.Sprintf("%06X", aircraft.ICAO24)
for _, err := range validation.Errors {
log.Printf("[POSITION_VALIDATION] ICAO %s: REJECTED - %s", icaoHex, err)
}
}
// Log warnings even for valid positions
for _, warning := range validation.Warnings {
icaoHex := fmt.Sprintf("%06X", aircraft.ICAO24)
log.Printf("[POSITION_VALIDATION] ICAO %s: WARNING - %s", icaoHex, warning)
}
} }
// Signal history // Signal history
@ -809,6 +868,130 @@ func calculateDistanceBearing(lat1, lon1, lat2, lon2 float64) (float64, float64)
return distance, bearing return distance, bearing
} }
// validatePosition performs comprehensive validation of aircraft position data to filter out
// obviously incorrect flight paths and implausible position updates.
//
// This function implements multiple validation checks to improve data quality:
//
// 1. **Coordinate Validation**: Ensures latitude/longitude are within Earth's bounds
// 2. **Altitude Validation**: Rejects impossible altitudes (negative or > FL600)
// 3. **Speed Validation**: Calculates implied speed and rejects >Mach 3 movements
// 4. **Distance Validation**: Rejects position jumps >500nm without time justification
// 5. **Time Validation**: Ensures timestamps are chronologically consistent
//
// Parameters:
// - aircraft: New aircraft position data to validate
// - state: Current aircraft state with position history
// - timestamp: Timestamp of the new position data
//
// Returns:
// - ValidationResult with valid flag and detailed error/warning messages
func (m *Merger) validatePosition(aircraft *modes.Aircraft, state *AircraftState, timestamp time.Time) *ValidationResult {
result := &ValidationResult{
Valid: true,
Errors: make([]string, 0),
Warnings: make([]string, 0),
}
// Skip validation if no position data
if aircraft.Latitude == 0 && aircraft.Longitude == 0 {
return result // No position to validate
}
// 1. Geographic coordinate validation
if aircraft.Latitude < MinLatitude || aircraft.Latitude > MaxLatitude {
result.Valid = false
result.Errors = append(result.Errors, fmt.Sprintf("Invalid latitude: %.6f (must be between %.1f and %.1f)",
aircraft.Latitude, MinLatitude, MaxLatitude))
}
if aircraft.Longitude < MinLongitude || aircraft.Longitude > MaxLongitude {
result.Valid = false
result.Errors = append(result.Errors, fmt.Sprintf("Invalid longitude: %.6f (must be between %.1f and %.1f)",
aircraft.Longitude, MinLongitude, MaxLongitude))
}
// 2. Altitude validation
if aircraft.Altitude != 0 { // Only validate non-zero altitudes
if aircraft.Altitude < MinAltitudeFeet {
result.Valid = false
result.Errors = append(result.Errors, fmt.Sprintf("Impossible altitude: %d feet (below minimum %d)",
aircraft.Altitude, MinAltitudeFeet))
}
if aircraft.Altitude > MaxAltitudeFeet {
result.Valid = false
result.Errors = append(result.Errors, fmt.Sprintf("Impossible altitude: %d feet (above maximum %d)",
aircraft.Altitude, MaxAltitudeFeet))
}
}
// 3. Speed and distance validation (requires position history)
if len(state.PositionHistory) > 0 && state.Latitude != 0 && state.Longitude != 0 {
lastPos := state.PositionHistory[len(state.PositionHistory)-1]
// Calculate distance between positions
distance, _ := calculateDistanceBearing(lastPos.Latitude, lastPos.Longitude,
aircraft.Latitude, aircraft.Longitude)
// Calculate time difference
timeDiff := timestamp.Sub(lastPos.Time).Seconds()
if timeDiff > 0 {
// Calculate implied speed in knots
distanceNm := distance / NmToKm
speedKnots := (distanceNm / timeDiff) * 3600 // Convert to knots per hour
// Distance validation: reject jumps >500nm
if distanceNm > MaxDistanceNautMiles {
result.Valid = false
result.Errors = append(result.Errors, fmt.Sprintf("Impossible position jump: %.1f nm in %.1f seconds (max allowed: %.1f nm)",
distanceNm, timeDiff, MaxDistanceNautMiles))
}
// Speed validation: reject >2000 knots (roughly Mach 3)
if speedKnots > MaxSpeedKnots {
result.Valid = false
result.Errors = append(result.Errors, fmt.Sprintf("Impossible speed: %.0f knots (max allowed: %.0f knots)",
speedKnots, MaxSpeedKnots))
}
// Warning for high but possible speeds (>800 knots)
if speedKnots > 800 && speedKnots <= MaxSpeedKnots {
result.Warnings = append(result.Warnings, fmt.Sprintf("High speed detected: %.0f knots", speedKnots))
}
} else if timeDiff < 0 {
// 4. Time validation: reject out-of-order timestamps
result.Valid = false
result.Errors = append(result.Errors, fmt.Sprintf("Out-of-order timestamp: %.1f seconds in the past", -timeDiff))
}
}
// 5. Aircraft-specific validations based on reported speed vs. position
if aircraft.GroundSpeed > 0 && len(state.PositionHistory) > 0 {
// Check if reported ground speed is consistent with position changes
lastPos := state.PositionHistory[len(state.PositionHistory)-1]
distance, _ := calculateDistanceBearing(lastPos.Latitude, lastPos.Longitude,
aircraft.Latitude, aircraft.Longitude)
timeDiff := timestamp.Sub(lastPos.Time).Seconds()
if timeDiff > 0 {
distanceNm := distance / NmToKm
impliedSpeed := (distanceNm / timeDiff) * 3600
reportedSpeed := float64(aircraft.GroundSpeed)
// Warning if speeds differ significantly (>100 knots difference)
if math.Abs(impliedSpeed-reportedSpeed) > 100 && reportedSpeed > 50 {
result.Warnings = append(result.Warnings,
fmt.Sprintf("Speed inconsistency: reported %d knots, implied %.0f knots",
aircraft.GroundSpeed, impliedSpeed))
}
}
}
return result
}
// Close closes the merger and releases resources // Close closes the merger and releases resources
func (m *Merger) Close() error { func (m *Merger) Close() error {
m.mu.Lock() m.mu.Lock()