Cleanup: - Remove unused aircraft-icon.svg (replaced by type-specific icons) - Remove test files: beast-dump-with-heli.bin, beast.test, main, old.json, ux.png - Remove duplicate config.json.example (kept config.example.json) - Remove empty internal/coverage/ directory - Move CLAUDE.md to project root - Update assets.go documentation to reflect current icon structure - Format all Go code with gofmt Server Host Binding Fix: - Fix critical bug where server host configuration was ignored - Add host parameter to Server struct and NewWebServer constructor - Rename NewServer to NewWebServer for better clarity - Fix IPv6 address formatting in server binding (wrap in brackets) - Update startup message to show correct bind address format - Support localhost-only, IPv4, IPv6, and interface-specific binding This resolves the "too many colons in address" error for IPv6 hosts like ::1 and enables proper localhost-only deployment as configured. Closes #15 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
821 lines
29 KiB
Go
821 lines
29 KiB
Go
// Package merger provides multi-source aircraft data fusion and conflict resolution.
|
|
//
|
|
// This package is the core of SkyView's multi-source capability, handling the complex
|
|
// task of merging aircraft data from multiple ADS-B receivers. It provides:
|
|
// - Intelligent data fusion based on signal strength and recency
|
|
// - Historical tracking of aircraft positions, altitudes, and speeds
|
|
// - Per-source signal quality and update rate tracking
|
|
// - Automatic conflict resolution when sources disagree
|
|
// - Comprehensive aircraft state management
|
|
// - Distance and bearing calculations from receivers
|
|
//
|
|
// The merger uses several strategies for data fusion:
|
|
// - Position data: Uses source with strongest signal
|
|
// - Recent data: Prefers newer information for dynamic values
|
|
// - Best quality: Prioritizes higher accuracy navigation data
|
|
// - History tracking: Maintains trails for visualization and analysis
|
|
//
|
|
// All data structures are designed for concurrent access and JSON serialization
|
|
// for web API consumption.
|
|
package merger
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"sync"
|
|
"time"
|
|
|
|
"skyview/internal/icao"
|
|
"skyview/internal/modes"
|
|
)
|
|
|
|
const (
|
|
// MaxDistance represents an infinite distance for initialization
|
|
MaxDistance = float64(999999)
|
|
)
|
|
|
|
// Source represents a data source (dump1090 receiver or similar ADS-B source).
|
|
// It contains both static configuration and dynamic status information used
|
|
// for data fusion decisions and source monitoring.
|
|
type Source struct {
|
|
ID string `json:"id"` // Unique identifier for this source
|
|
Name string `json:"name"` // Human-readable name
|
|
Host string `json:"host"` // Hostname or IP address
|
|
Port int `json:"port"` // TCP port number
|
|
Latitude float64 `json:"latitude"` // Receiver location latitude
|
|
Longitude float64 `json:"longitude"` // Receiver location longitude
|
|
Altitude float64 `json:"altitude"` // Receiver altitude above sea level
|
|
Active bool `json:"active"` // Currently connected and receiving data
|
|
LastSeen time.Time `json:"last_seen"` // Timestamp of last received message
|
|
Messages int64 `json:"messages"` // Total messages processed from this source
|
|
Aircraft int `json:"aircraft"` // Current aircraft count from this source
|
|
}
|
|
|
|
// AircraftState represents the complete merged aircraft state from all sources.
|
|
//
|
|
// This structure combines the basic aircraft data from Mode S decoding with
|
|
// multi-source metadata, historical tracking, and derived information:
|
|
// - Embedded modes.Aircraft with all decoded ADS-B data
|
|
// - Per-source signal and quality information
|
|
// - Historical trails for position, altitude, speed, and signal strength
|
|
// - Distance and bearing from receivers
|
|
// - Update rate and age calculations
|
|
// - Data provenance tracking
|
|
type AircraftState struct {
|
|
*modes.Aircraft // Embedded decoded aircraft data
|
|
Sources map[string]*SourceData `json:"sources"` // Per-source information
|
|
LastUpdate time.Time `json:"last_update"` // Last update from any source
|
|
FirstSeen time.Time `json:"first_seen"` // First time this aircraft was seen
|
|
TotalMessages int64 `json:"total_messages"` // Total messages received for this aircraft
|
|
PositionHistory []PositionPoint `json:"position_history"` // Trail of position updates
|
|
SignalHistory []SignalPoint `json:"signal_history"` // Signal strength over time
|
|
AltitudeHistory []AltitudePoint `json:"altitude_history"` // Altitude and vertical rate history
|
|
SpeedHistory []SpeedPoint `json:"speed_history"` // Speed and track history
|
|
Distance float64 `json:"distance"` // Distance from closest receiver (km)
|
|
Bearing float64 `json:"bearing"` // Bearing from closest receiver (degrees)
|
|
Age float64 `json:"age"` // Seconds since last update
|
|
MLATSources []string `json:"mlat_sources"` // Sources providing MLAT position data
|
|
PositionSource string `json:"position_source"` // Source providing current position
|
|
UpdateRate float64 `json:"update_rate"` // Recent updates per second
|
|
Country string `json:"country"` // Country of registration
|
|
CountryCode string `json:"country_code"` // ISO country code
|
|
Flag string `json:"flag"` // Country flag emoji
|
|
}
|
|
|
|
// MarshalJSON provides custom JSON marshaling for AircraftState to format ICAO24 as hex.
|
|
func (a *AircraftState) MarshalJSON() ([]byte, error) {
|
|
// Create a struct that mirrors AircraftState but with ICAO24 as string
|
|
return json.Marshal(&struct {
|
|
// From embedded modes.Aircraft
|
|
ICAO24 string `json:"ICAO24"`
|
|
Callsign string `json:"Callsign"`
|
|
Latitude float64 `json:"Latitude"`
|
|
Longitude float64 `json:"Longitude"`
|
|
Altitude int `json:"Altitude"`
|
|
BaroAltitude int `json:"BaroAltitude"`
|
|
GeomAltitude int `json:"GeomAltitude"`
|
|
VerticalRate int `json:"VerticalRate"`
|
|
GroundSpeed int `json:"GroundSpeed"`
|
|
Track int `json:"Track"`
|
|
Heading int `json:"Heading"`
|
|
Category string `json:"Category"`
|
|
Squawk string `json:"Squawk"`
|
|
Emergency string `json:"Emergency"`
|
|
OnGround bool `json:"OnGround"`
|
|
Alert bool `json:"Alert"`
|
|
SPI bool `json:"SPI"`
|
|
NACp uint8 `json:"NACp"`
|
|
NACv uint8 `json:"NACv"`
|
|
SIL uint8 `json:"SIL"`
|
|
SelectedAltitude int `json:"SelectedAltitude"`
|
|
SelectedHeading float64 `json:"SelectedHeading"`
|
|
BaroSetting float64 `json:"BaroSetting"`
|
|
|
|
// From AircraftState
|
|
Sources map[string]*SourceData `json:"sources"`
|
|
LastUpdate time.Time `json:"last_update"`
|
|
FirstSeen time.Time `json:"first_seen"`
|
|
TotalMessages int64 `json:"total_messages"`
|
|
PositionHistory []PositionPoint `json:"position_history"`
|
|
SignalHistory []SignalPoint `json:"signal_history"`
|
|
AltitudeHistory []AltitudePoint `json:"altitude_history"`
|
|
SpeedHistory []SpeedPoint `json:"speed_history"`
|
|
Distance float64 `json:"distance"`
|
|
Bearing float64 `json:"bearing"`
|
|
Age float64 `json:"age"`
|
|
MLATSources []string `json:"mlat_sources"`
|
|
PositionSource string `json:"position_source"`
|
|
UpdateRate float64 `json:"update_rate"`
|
|
Country string `json:"country"`
|
|
CountryCode string `json:"country_code"`
|
|
Flag string `json:"flag"`
|
|
}{
|
|
// Copy all fields from Aircraft
|
|
ICAO24: fmt.Sprintf("%06X", a.Aircraft.ICAO24),
|
|
Callsign: a.Aircraft.Callsign,
|
|
Latitude: a.Aircraft.Latitude,
|
|
Longitude: a.Aircraft.Longitude,
|
|
Altitude: a.Aircraft.Altitude,
|
|
BaroAltitude: a.Aircraft.BaroAltitude,
|
|
GeomAltitude: a.Aircraft.GeomAltitude,
|
|
VerticalRate: a.Aircraft.VerticalRate,
|
|
GroundSpeed: a.Aircraft.GroundSpeed,
|
|
Track: a.Aircraft.Track,
|
|
Heading: a.Aircraft.Heading,
|
|
Category: a.Aircraft.Category,
|
|
Squawk: a.Aircraft.Squawk,
|
|
Emergency: a.Aircraft.Emergency,
|
|
OnGround: a.Aircraft.OnGround,
|
|
Alert: a.Aircraft.Alert,
|
|
SPI: a.Aircraft.SPI,
|
|
NACp: a.Aircraft.NACp,
|
|
NACv: a.Aircraft.NACv,
|
|
SIL: a.Aircraft.SIL,
|
|
SelectedAltitude: a.Aircraft.SelectedAltitude,
|
|
SelectedHeading: a.Aircraft.SelectedHeading,
|
|
BaroSetting: a.Aircraft.BaroSetting,
|
|
|
|
// Copy all fields from AircraftState
|
|
Sources: a.Sources,
|
|
LastUpdate: a.LastUpdate,
|
|
FirstSeen: a.FirstSeen,
|
|
TotalMessages: a.TotalMessages,
|
|
PositionHistory: a.PositionHistory,
|
|
SignalHistory: a.SignalHistory,
|
|
AltitudeHistory: a.AltitudeHistory,
|
|
SpeedHistory: a.SpeedHistory,
|
|
Distance: a.Distance,
|
|
Bearing: a.Bearing,
|
|
Age: a.Age,
|
|
MLATSources: a.MLATSources,
|
|
PositionSource: a.PositionSource,
|
|
UpdateRate: a.UpdateRate,
|
|
Country: a.Country,
|
|
CountryCode: a.CountryCode,
|
|
Flag: a.Flag,
|
|
})
|
|
}
|
|
|
|
// SourceData represents data quality and statistics for a specific source-aircraft pair.
|
|
// This information is used for data fusion decisions and signal quality analysis.
|
|
type SourceData struct {
|
|
SourceID string `json:"source_id"` // Unique identifier of the source
|
|
SignalLevel float64 `json:"signal_level"` // Signal strength (dBFS)
|
|
Messages int64 `json:"messages"` // Messages received from this source
|
|
LastSeen time.Time `json:"last_seen"` // Last message timestamp from this source
|
|
Distance float64 `json:"distance"` // Distance from receiver to aircraft (km)
|
|
Bearing float64 `json:"bearing"` // Bearing from receiver to aircraft (degrees)
|
|
UpdateRate float64 `json:"update_rate"` // Updates per second from this source
|
|
}
|
|
|
|
// PositionPoint represents a timestamped position update in aircraft history.
|
|
// Used to build position trails for visualization and track analysis.
|
|
type PositionPoint struct {
|
|
Time time.Time `json:"time"` // Timestamp when position was received
|
|
Latitude float64 `json:"lat"` // Latitude in decimal degrees
|
|
Longitude float64 `json:"lon"` // Longitude in decimal degrees
|
|
Source string `json:"source"` // Source that provided this position
|
|
}
|
|
|
|
// SignalPoint represents a timestamped signal strength measurement.
|
|
// Used to track signal quality over time and analyze receiver performance.
|
|
type SignalPoint struct {
|
|
Time time.Time `json:"time"` // Timestamp when signal was measured
|
|
Signal float64 `json:"signal"` // Signal strength in dBFS
|
|
Source string `json:"source"` // Source that measured this signal
|
|
}
|
|
|
|
// AltitudePoint represents a timestamped altitude measurement.
|
|
// Includes vertical rate for flight profile analysis.
|
|
type AltitudePoint struct {
|
|
Time time.Time `json:"time"` // Timestamp when altitude was received
|
|
Altitude int `json:"altitude"` // Altitude in feet
|
|
VRate int `json:"vrate"` // Vertical rate in feet per minute
|
|
}
|
|
|
|
// SpeedPoint represents a timestamped speed and track measurement.
|
|
// Used for aircraft performance analysis and track prediction.
|
|
type SpeedPoint struct {
|
|
Time time.Time `json:"time"` // Timestamp when speed was received
|
|
GroundSpeed int `json:"ground_speed"` // Ground speed in knots (integer)
|
|
Track int `json:"track"` // Track angle in degrees (0-359)
|
|
}
|
|
|
|
// Merger handles merging aircraft data from multiple sources with intelligent conflict resolution.
|
|
//
|
|
// The merger maintains:
|
|
// - Complete aircraft states with multi-source data fusion
|
|
// - Source registry with connection status and statistics
|
|
// - Historical data with configurable retention limits
|
|
// - Update rate metrics for performance monitoring
|
|
// - Automatic stale aircraft cleanup
|
|
//
|
|
// Thread safety is provided by RWMutex for concurrent read access while
|
|
// maintaining write consistency during updates.
|
|
type Merger struct {
|
|
aircraft map[uint32]*AircraftState // ICAO24 -> merged aircraft state
|
|
sources map[string]*Source // Source ID -> source information
|
|
icaoDB *icao.Database // ICAO country lookup database
|
|
mu sync.RWMutex // Protects all maps and slices
|
|
historyLimit int // Maximum history points to retain
|
|
staleTimeout time.Duration // Time before aircraft considered stale (15 seconds)
|
|
updateMetrics map[uint32]*updateMetric // ICAO24 -> update rate calculation data
|
|
}
|
|
|
|
// updateMetric tracks recent update times for calculating update rates.
|
|
// Used internally to provide real-time update frequency information.
|
|
type updateMetric struct {
|
|
updates []time.Time // Recent update timestamps (last 30 seconds)
|
|
}
|
|
|
|
// NewMerger creates a new aircraft data merger with default configuration.
|
|
//
|
|
// Default settings:
|
|
// - History limit: 500 points per aircraft
|
|
// - Stale timeout: 15 seconds
|
|
// - Empty aircraft and source maps
|
|
// - Update metrics tracking enabled
|
|
//
|
|
// The merger is ready for immediate use after creation.
|
|
func NewMerger() (*Merger, error) {
|
|
icaoDB, err := icao.NewDatabase()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize ICAO database: %w", err)
|
|
}
|
|
|
|
return &Merger{
|
|
aircraft: make(map[uint32]*AircraftState),
|
|
sources: make(map[string]*Source),
|
|
icaoDB: icaoDB,
|
|
historyLimit: 500,
|
|
staleTimeout: 15 * time.Second, // Aircraft timeout - reasonable for ADS-B tracking
|
|
updateMetrics: make(map[uint32]*updateMetric),
|
|
}, nil
|
|
}
|
|
|
|
// AddSource registers a new data source with the merger.
|
|
//
|
|
// The source must have a unique ID and will be available for aircraft
|
|
// data updates immediately. Sources can be added at any time, even
|
|
// after the merger is actively processing data.
|
|
//
|
|
// Parameters:
|
|
// - source: Source configuration with ID, location, and connection details
|
|
func (m *Merger) AddSource(source *Source) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.sources[source.ID] = source
|
|
}
|
|
|
|
// UpdateAircraft merges new aircraft data from a source using intelligent fusion strategies.
|
|
//
|
|
// This is the core method of the merger, handling:
|
|
// 1. Aircraft state creation for new aircraft
|
|
// 2. Source data tracking and statistics
|
|
// 3. Multi-source data fusion with conflict resolution
|
|
// 4. Historical data updates with retention limits
|
|
// 5. Distance and bearing calculations
|
|
// 6. Update rate metrics
|
|
// 7. Source status maintenance
|
|
//
|
|
// Data fusion strategies:
|
|
// - Position: Use source with strongest signal
|
|
// - Dynamic data: Prefer most recent updates
|
|
// - Quality indicators: Keep highest accuracy values
|
|
// - Identity: Use most recent non-empty values
|
|
//
|
|
// Parameters:
|
|
// - sourceID: Identifier of the source providing this data
|
|
// - aircraft: Decoded Mode S/ADS-B aircraft data
|
|
// - signal: Signal strength in dBFS
|
|
// - timestamp: When this data was received
|
|
func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signal float64, timestamp time.Time) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Get or create aircraft state
|
|
state, exists := m.aircraft[aircraft.ICAO24]
|
|
if !exists {
|
|
state = &AircraftState{
|
|
Aircraft: aircraft,
|
|
Sources: make(map[string]*SourceData),
|
|
FirstSeen: timestamp,
|
|
PositionHistory: make([]PositionPoint, 0),
|
|
SignalHistory: make([]SignalPoint, 0),
|
|
AltitudeHistory: make([]AltitudePoint, 0),
|
|
SpeedHistory: make([]SpeedPoint, 0),
|
|
}
|
|
|
|
// Lookup country information for new aircraft
|
|
icaoHex := fmt.Sprintf("%06X", aircraft.ICAO24)
|
|
if countryInfo, err := m.icaoDB.LookupCountry(icaoHex); err == nil {
|
|
state.Country = countryInfo.Country
|
|
state.CountryCode = countryInfo.CountryCode
|
|
state.Flag = countryInfo.Flag
|
|
} else {
|
|
// Fallback to unknown if lookup fails
|
|
state.Country = "Unknown"
|
|
state.CountryCode = "XX"
|
|
state.Flag = "🏳️"
|
|
}
|
|
|
|
m.aircraft[aircraft.ICAO24] = state
|
|
m.updateMetrics[aircraft.ICAO24] = &updateMetric{
|
|
updates: make([]time.Time, 0),
|
|
}
|
|
}
|
|
// Note: For existing aircraft, we don't overwrite state.Aircraft here
|
|
// The mergeAircraftData function will handle selective field updates
|
|
|
|
// Update or create source data
|
|
srcData, srcExists := state.Sources[sourceID]
|
|
if !srcExists {
|
|
srcData = &SourceData{
|
|
SourceID: sourceID,
|
|
}
|
|
state.Sources[sourceID] = srcData
|
|
}
|
|
|
|
// Update source data
|
|
srcData.SignalLevel = signal
|
|
srcData.Messages++
|
|
srcData.LastSeen = timestamp
|
|
|
|
// Calculate distance and bearing from source
|
|
if source, ok := m.sources[sourceID]; ok && aircraft.Latitude != 0 && aircraft.Longitude != 0 {
|
|
srcData.Distance, srcData.Bearing = calculateDistanceBearing(
|
|
source.Latitude, source.Longitude,
|
|
aircraft.Latitude, aircraft.Longitude,
|
|
)
|
|
}
|
|
|
|
// Update merged aircraft data (use best/newest data)
|
|
m.mergeAircraftData(state, aircraft, sourceID, timestamp)
|
|
|
|
// Update histories
|
|
m.updateHistories(state, aircraft, sourceID, signal, timestamp)
|
|
|
|
// Update metrics
|
|
m.updateUpdateRate(aircraft.ICAO24, timestamp)
|
|
|
|
// Update source statistics
|
|
if source, ok := m.sources[sourceID]; ok {
|
|
source.LastSeen = timestamp
|
|
source.Messages++
|
|
source.Active = true
|
|
}
|
|
|
|
state.LastUpdate = timestamp
|
|
state.TotalMessages++
|
|
}
|
|
|
|
// mergeAircraftData intelligently merges data from multiple sources with conflict resolution.
|
|
//
|
|
// This method implements the core data fusion logic:
|
|
//
|
|
// Position Data:
|
|
// - Uses source with strongest signal strength for best accuracy
|
|
// - Falls back to any available position if none exists
|
|
// - Tracks which source provided the current position
|
|
//
|
|
// Dynamic Data (altitude, speed, heading, vertical rate):
|
|
// - Always uses most recent data to reflect current aircraft state
|
|
// - Assumes more recent data is more accurate for rapidly changing values
|
|
//
|
|
// Identity Data (callsign, squawk, category):
|
|
// - Uses most recent non-empty values
|
|
// - Preserves existing values when new data is empty
|
|
//
|
|
// Quality Indicators (NACp, NACv, SIL):
|
|
// - Uses highest available accuracy values
|
|
// - Maintains best quality indicators across all sources
|
|
//
|
|
// Parameters:
|
|
// - state: Current merged aircraft state to update
|
|
// - new: New aircraft data from a source
|
|
// - sourceID: Identifier of source providing new data
|
|
// - timestamp: Timestamp of new data
|
|
func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, sourceID string, timestamp time.Time) {
|
|
// Position - use source with best signal or most recent
|
|
if new.Latitude != 0 && new.Longitude != 0 {
|
|
updatePosition := false
|
|
|
|
if state.Latitude == 0 {
|
|
// First position update
|
|
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
|
|
}
|
|
}
|
|
|
|
// Altitude - use most recent
|
|
if new.Altitude != 0 {
|
|
state.Altitude = new.Altitude
|
|
}
|
|
if new.BaroAltitude != 0 {
|
|
state.BaroAltitude = new.BaroAltitude
|
|
}
|
|
if new.GeomAltitude != 0 {
|
|
state.GeomAltitude = new.GeomAltitude
|
|
}
|
|
|
|
// Speed and track - use most recent
|
|
if new.GroundSpeed != 0 {
|
|
state.GroundSpeed = new.GroundSpeed
|
|
}
|
|
if new.Track != 0 {
|
|
state.Track = new.Track
|
|
}
|
|
if new.Heading != 0 {
|
|
state.Heading = new.Heading
|
|
}
|
|
|
|
// Vertical rate - use most recent
|
|
if new.VerticalRate != 0 {
|
|
state.VerticalRate = new.VerticalRate
|
|
}
|
|
|
|
// Identity - use most recent non-empty
|
|
if new.Callsign != "" {
|
|
state.Callsign = new.Callsign
|
|
}
|
|
if new.Squawk != "" {
|
|
state.Squawk = new.Squawk
|
|
}
|
|
if new.Category != "" {
|
|
state.Category = new.Category
|
|
}
|
|
|
|
// Status - use most recent
|
|
if new.Emergency != "" {
|
|
state.Emergency = new.Emergency
|
|
}
|
|
state.OnGround = new.OnGround
|
|
state.Alert = new.Alert
|
|
state.SPI = new.SPI
|
|
|
|
// Navigation accuracy - use best available
|
|
if new.NACp > state.NACp {
|
|
state.NACp = new.NACp
|
|
}
|
|
if new.NACv > state.NACv {
|
|
state.NACv = new.NACv
|
|
}
|
|
if new.SIL > state.SIL {
|
|
state.SIL = new.SIL
|
|
}
|
|
|
|
// Selected values - use most recent
|
|
if new.SelectedAltitude != 0 {
|
|
state.SelectedAltitude = new.SelectedAltitude
|
|
}
|
|
if new.SelectedHeading != 0 {
|
|
state.SelectedHeading = new.SelectedHeading
|
|
}
|
|
if new.BaroSetting != 0 {
|
|
state.BaroSetting = new.BaroSetting
|
|
}
|
|
}
|
|
|
|
// updateHistories adds data points to historical tracking arrays.
|
|
//
|
|
// Maintains time-series data for:
|
|
// - Position trail for track visualization
|
|
// - Signal strength for coverage analysis
|
|
// - Altitude profile for flight analysis
|
|
// - Speed history for performance tracking
|
|
//
|
|
// Each history array is limited by historyLimit to prevent unbounded growth.
|
|
// Only non-zero values are recorded to avoid cluttering histories with
|
|
// invalid or missing data points.
|
|
//
|
|
// Parameters:
|
|
// - state: Aircraft state to update histories for
|
|
// - aircraft: New aircraft data containing values to record
|
|
// - sourceID: Source providing this data point
|
|
// - signal: Signal strength measurement
|
|
// - timestamp: When this data was received
|
|
func (m *Merger) updateHistories(state *AircraftState, aircraft *modes.Aircraft, sourceID string, signal float64, timestamp time.Time) {
|
|
// Position history
|
|
if aircraft.Latitude != 0 && aircraft.Longitude != 0 {
|
|
state.PositionHistory = append(state.PositionHistory, PositionPoint{
|
|
Time: timestamp,
|
|
Latitude: aircraft.Latitude,
|
|
Longitude: aircraft.Longitude,
|
|
Source: sourceID,
|
|
})
|
|
}
|
|
|
|
// Signal history
|
|
if signal != 0 {
|
|
state.SignalHistory = append(state.SignalHistory, SignalPoint{
|
|
Time: timestamp,
|
|
Signal: signal,
|
|
Source: sourceID,
|
|
})
|
|
}
|
|
|
|
// Altitude history
|
|
if aircraft.Altitude != 0 {
|
|
state.AltitudeHistory = append(state.AltitudeHistory, AltitudePoint{
|
|
Time: timestamp,
|
|
Altitude: aircraft.Altitude,
|
|
VRate: aircraft.VerticalRate,
|
|
})
|
|
}
|
|
|
|
// Speed history
|
|
if aircraft.GroundSpeed != 0 {
|
|
state.SpeedHistory = append(state.SpeedHistory, SpeedPoint{
|
|
Time: timestamp,
|
|
GroundSpeed: aircraft.GroundSpeed,
|
|
Track: aircraft.Track,
|
|
})
|
|
}
|
|
|
|
// Trim histories if they exceed limit
|
|
if len(state.PositionHistory) > m.historyLimit {
|
|
state.PositionHistory = state.PositionHistory[len(state.PositionHistory)-m.historyLimit:]
|
|
}
|
|
if len(state.SignalHistory) > m.historyLimit {
|
|
state.SignalHistory = state.SignalHistory[len(state.SignalHistory)-m.historyLimit:]
|
|
}
|
|
if len(state.AltitudeHistory) > m.historyLimit {
|
|
state.AltitudeHistory = state.AltitudeHistory[len(state.AltitudeHistory)-m.historyLimit:]
|
|
}
|
|
if len(state.SpeedHistory) > m.historyLimit {
|
|
state.SpeedHistory = state.SpeedHistory[len(state.SpeedHistory)-m.historyLimit:]
|
|
}
|
|
}
|
|
|
|
// updateUpdateRate calculates and maintains the message update rate for an aircraft.
|
|
//
|
|
// The calculation:
|
|
// 1. Records the timestamp of each update
|
|
// 2. Maintains a sliding 30-second window of updates
|
|
// 3. Calculates updates per second over this window
|
|
// 4. Updates the aircraft's UpdateRate field
|
|
//
|
|
// This provides real-time feedback on data quality and can help identify
|
|
// aircraft that are updating frequently (close, good signal) vs infrequently
|
|
// (distant, weak signal).
|
|
//
|
|
// Parameters:
|
|
// - icao: ICAO24 address of the aircraft
|
|
// - timestamp: Timestamp of this update
|
|
func (m *Merger) updateUpdateRate(icao uint32, timestamp time.Time) {
|
|
metric := m.updateMetrics[icao]
|
|
metric.updates = append(metric.updates, timestamp)
|
|
|
|
// Keep only last 30 seconds of updates
|
|
cutoff := timestamp.Add(-30 * time.Second)
|
|
for len(metric.updates) > 0 && metric.updates[0].Before(cutoff) {
|
|
metric.updates = metric.updates[1:]
|
|
}
|
|
|
|
if len(metric.updates) > 1 {
|
|
duration := metric.updates[len(metric.updates)-1].Sub(metric.updates[0]).Seconds()
|
|
if duration > 0 {
|
|
if state, ok := m.aircraft[icao]; ok {
|
|
state.UpdateRate = float64(len(metric.updates)) / duration
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// getBestSignalSource identifies the source with the strongest signal for this aircraft.
|
|
//
|
|
// Used in position data fusion to determine which source should provide
|
|
// the authoritative position. Sources with stronger signals typically
|
|
// provide more accurate position data.
|
|
//
|
|
// Parameters:
|
|
// - state: Aircraft state containing per-source signal data
|
|
//
|
|
// Returns the source ID with the highest signal level, or empty string if none.
|
|
func (m *Merger) getBestSignalSource(state *AircraftState) string {
|
|
var bestSource string
|
|
var bestSignal float64 = -999
|
|
|
|
for srcID, srcData := range state.Sources {
|
|
if srcData.SignalLevel > bestSignal {
|
|
bestSignal = srcData.SignalLevel
|
|
bestSource = srcID
|
|
}
|
|
}
|
|
|
|
return bestSource
|
|
}
|
|
|
|
// GetAircraft returns a snapshot of all current aircraft states.
|
|
//
|
|
// This method:
|
|
// 1. Filters out stale aircraft (older than staleTimeout)
|
|
// 2. Calculates current age for each aircraft
|
|
// 3. Determines closest receiver distance and bearing
|
|
// 4. Returns copies to prevent external modification
|
|
//
|
|
// The returned map uses ICAO24 addresses as keys and can be safely
|
|
// used by multiple goroutines without affecting the internal state.
|
|
//
|
|
// Returns a map of ICAO24 -> AircraftState for all non-stale aircraft.
|
|
func (m *Merger) GetAircraft() map[uint32]*AircraftState {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
// Create copy and calculate ages
|
|
result := make(map[uint32]*AircraftState)
|
|
now := time.Now()
|
|
|
|
for icao, state := range m.aircraft {
|
|
// Skip stale aircraft
|
|
if now.Sub(state.LastUpdate) > m.staleTimeout {
|
|
continue
|
|
}
|
|
|
|
// Calculate age
|
|
stateCopy := *state
|
|
stateCopy.Age = now.Sub(state.LastUpdate).Seconds()
|
|
|
|
// Find closest receiver distance
|
|
minDistance := MaxDistance
|
|
for _, srcData := range state.Sources {
|
|
if srcData.Distance > 0 && srcData.Distance < minDistance {
|
|
minDistance = srcData.Distance
|
|
stateCopy.Distance = srcData.Distance
|
|
stateCopy.Bearing = srcData.Bearing
|
|
}
|
|
}
|
|
|
|
result[icao] = &stateCopy
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// GetSources returns all registered data sources.
|
|
//
|
|
// Provides access to source configuration, status, and statistics.
|
|
// Used by the web API to display source information and connection status.
|
|
//
|
|
// Returns a slice of all registered sources (active and inactive).
|
|
func (m *Merger) GetSources() []*Source {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
sources := make([]*Source, 0, len(m.sources))
|
|
for _, src := range m.sources {
|
|
sources = append(sources, src)
|
|
}
|
|
return sources
|
|
}
|
|
|
|
// GetStatistics returns comprehensive merger and system statistics.
|
|
//
|
|
// The statistics include:
|
|
// - total_aircraft: Current number of tracked aircraft
|
|
// - total_messages: Sum of all messages processed
|
|
// - active_sources: Number of currently connected sources
|
|
// - aircraft_by_sources: Distribution of aircraft by number of tracking sources
|
|
//
|
|
// The aircraft_by_sources map shows data quality - aircraft tracked by
|
|
// multiple sources generally have better position accuracy and reliability.
|
|
//
|
|
// Returns a map suitable for JSON serialization and web display.
|
|
func (m *Merger) GetStatistics() map[string]interface{} {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
totalMessages := int64(0)
|
|
activeSources := 0
|
|
aircraftBySources := make(map[int]int) // Count by number of sources
|
|
|
|
for _, state := range m.aircraft {
|
|
totalMessages += state.TotalMessages
|
|
numSources := len(state.Sources)
|
|
aircraftBySources[numSources]++
|
|
}
|
|
|
|
for _, src := range m.sources {
|
|
if src.Active {
|
|
activeSources++
|
|
}
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"total_aircraft": len(m.aircraft),
|
|
"total_messages": totalMessages,
|
|
"active_sources": activeSources,
|
|
"aircraft_by_sources": aircraftBySources,
|
|
}
|
|
}
|
|
|
|
// CleanupStale removes aircraft that haven't been updated recently.
|
|
//
|
|
// Aircraft are considered stale if they haven't received updates for longer
|
|
// than staleTimeout (default 15 seconds). This cleanup prevents memory
|
|
// growth from aircraft that have left the coverage area or stopped transmitting.
|
|
//
|
|
// The cleanup also removes associated update metrics to free memory.
|
|
// This method is typically called periodically by the client manager.
|
|
func (m *Merger) CleanupStale() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
for icao, state := range m.aircraft {
|
|
if now.Sub(state.LastUpdate) > m.staleTimeout {
|
|
delete(m.aircraft, icao)
|
|
delete(m.updateMetrics, icao)
|
|
}
|
|
}
|
|
}
|
|
|
|
// calculateDistanceBearing computes great circle distance and bearing between two points.
|
|
//
|
|
// Uses the Haversine formula for distance calculation and forward azimuth
|
|
// for bearing calculation. Both calculations account for Earth's spherical
|
|
// nature for accuracy over long distances.
|
|
//
|
|
// Distance is calculated in kilometers, bearing in degrees (0-360° from North).
|
|
// This is used to calculate aircraft distance from receivers and for
|
|
// coverage analysis.
|
|
//
|
|
// Parameters:
|
|
// - lat1, lon1: First point (receiver) coordinates in decimal degrees
|
|
// - lat2, lon2: Second point (aircraft) coordinates in decimal degrees
|
|
//
|
|
// Returns:
|
|
// - distance: Great circle distance in kilometers
|
|
// - bearing: Forward azimuth in degrees (0° = North, 90° = East)
|
|
func calculateDistanceBearing(lat1, lon1, lat2, lon2 float64) (float64, float64) {
|
|
// Haversine formula for distance
|
|
const R = 6371.0 // Earth radius in km
|
|
|
|
dLat := (lat2 - lat1) * math.Pi / 180
|
|
dLon := (lon2 - lon1) * math.Pi / 180
|
|
|
|
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
|
math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*
|
|
math.Sin(dLon/2)*math.Sin(dLon/2)
|
|
|
|
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
|
distance := R * c
|
|
|
|
// Bearing calculation
|
|
y := math.Sin(dLon) * math.Cos(lat2*math.Pi/180)
|
|
x := math.Cos(lat1*math.Pi/180)*math.Sin(lat2*math.Pi/180) -
|
|
math.Sin(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Cos(dLon)
|
|
|
|
bearing := math.Atan2(y, x) * 180 / math.Pi
|
|
if bearing < 0 {
|
|
bearing += 360
|
|
}
|
|
|
|
return distance, bearing
|
|
}
|
|
|
|
// Close closes the merger and releases resources
|
|
func (m *Merger) Close() error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if m.icaoDB != nil {
|
|
return m.icaoDB.Close()
|
|
}
|
|
return nil
|
|
}
|