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:
parent
ec7d9e02af
commit
9242852c01
1 changed files with 207 additions and 24 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue