Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cmd/skyeye/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ var (
threatMonitoringInterval time.Duration
threatMonitoringRequiresSRS bool
mandatoryThreatRadiusNM float64
threatBRAABearingSpreadDeg float64
threatBRAARangeSpreadNM float64
enableTracing bool
discordWebhookID string
discordWebhookToken string
Expand Down Expand Up @@ -151,6 +153,8 @@ func init() {
skyeye.Flags().BoolVar(&enableThreatMonitoring, "threat-monitoring", true, "Enable THREAT monitoring")
skyeye.Flags().DurationVar(&threatMonitoringInterval, "threat-monitoring-interval", 3*time.Minute, "How often to broadcast THREAT")
skyeye.Flags().Float64Var(&mandatoryThreatRadiusNM, "mandatory-threat-radius", 25, "Briefed radius for mandatory THREAT calls, in nautical miles")
skyeye.Flags().Float64Var(&threatBRAABearingSpreadDeg, "threat-braa-bearing-spread", 5, "Bearing spread threshold for THREAT call BRAA-vs-bullseye decision, in degrees")
skyeye.Flags().Float64Var(&threatBRAARangeSpreadNM, "threat-braa-range-spread", 1, "Range spread threshold for THREAT call BRAA-vs-bullseye decision, in nautical miles")
skyeye.Flags().BoolVar(&threatMonitoringRequiresSRS, "threat-monitoring-requires-srs", true, "Require aircraft to be on SRS to receive THREAT calls. Only useful to disable when debugging")

// Tracing
Expand Down Expand Up @@ -415,6 +419,8 @@ func run(_ *cobra.Command, _ []string) {
ThreatMonitoringInterval: threatMonitoringInterval,
ThreatMonitoringRequiresSRS: threatMonitoringRequiresSRS,
MandatoryThreatRadius: unit.Length(mandatoryThreatRadiusNM) * unit.NauticalMile,
ThreatBRAABearingSpread: unit.Angle(threatBRAABearingSpreadDeg) * unit.Degree,
ThreatBRAARangeSpread: unit.Length(threatBRAARangeSpreadNM) * unit.NauticalMile,
EnableTracing: enableTracing,
DiscordWebhookID: discordWebhookID,
DiscorbWebhookToken: discordWebhookToken,
Expand Down
19 changes: 19 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,25 @@
# miles) is a reasonable choice for a modern setting, but you may wish to tune
# this based on mission requirements and player skill level.
#mandatory-threat-radius: 25
#
# The GCI may issue threat calls using either BRAA or Bullseye format.
# - If there is only one friendly aircraft in the area and on the controller's
# frequency, the call always uses BRAA format.
# - If there are multiple friendlies in the area and on frequency, the call
# uses BRAA format if the friendlies are close together or Bullseye format
# if the friendlies are split further apart.
# These two thresholds control how close together the friendlies need to be
# for the GCI to use BRAA instead of Bullseye.
#
# The bearing spread is the maximum difference in bearing (degrees) from each
# friendly to the hostile. If the bearings diverge more than this, the GCI
# uses Bullseye.
#threat-braa-bearing-spread: 5
#
# The range spread is the maximum difference in range (nautical miles) from
# each friendly to the hostile. If the ranges diverge more than this, the GCI
# uses Bullseye.
#threat-braa-range-spread: 1

# LOGGING
#
Expand Down
10 changes: 9 additions & 1 deletion internal/application/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,15 @@ func NewApplication(config conf.Configuration) (*Application, error) {

log.Info().Msg("constructing radar scope")

rdr := radar.New(config.Coalition, starts, updates, fades, config.MandatoryThreatRadius, config.EnableTerrainDetection)
// When threat monitoring requires SRS, the radar uses the SRS client to restrict the
// receiver set of each threat call to friendlies on frequency, so the BRAA/bullseye format
// reflects the pilots who will hear the call. When SRS is not required (debugging), leaving
// the client nil tells the radar to treat every friendly as a potential receiver.
var radarSRSClient *simpleradio.Client // separate variable so we can pass nil to the radar without affecting other references to srsClient
if config.ThreatMonitoringRequiresSRS {
radarSRSClient = srsClient
}
rdr := radar.New(config.Coalition, starts, updates, fades, config.MandatoryThreatRadius, config.ThreatBRAABearingSpread, config.ThreatBRAARangeSpread, config.EnableTerrainDetection, radarSRSClient)
log.Info().Msg("constructing GCI controller")
gciController := controller.New(
rdr,
Expand Down
6 changes: 6 additions & 0 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ type Configuration struct {
ThreatMonitoringInterval time.Duration
// MandatoryThreatRadius is the brief range at which a THREAT call is mandatory.
MandatoryThreatRadius unit.Length
// ThreatBRAABearingSpread is the maximum bearing divergence between receivers' BRAAs to a hostile
// before falling back to a bullseye call.
ThreatBRAABearingSpread unit.Angle
// ThreatBRAARangeSpread is the maximum range divergence between receivers' BRAAs to a hostile
// before falling back to a bullseye call.
ThreatBRAARangeSpread unit.Length
// ThreatMonitoringRequiresSRS controls whether threat calls are issued to aircraft that are not on an SRS frequency. This is mostly
// for debugging.
ThreatMonitoringRequiresSRS bool
Expand Down
20 changes: 20 additions & 0 deletions pkg/bearings/distance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package bearings

import (
"math"

"github.com/martinlindhe/unit"
)

// AngularDistance returns the smallest angular difference between two bearings, accounting for
// wrap-around (e.g. 001° and 359° are 2° apart, not 358°). The returned angle is always in the
// range [0°, 180°]. Both bearings are compared by their normalized value; the caller is
// responsible for ensuring they are expressed in the same reference frame (both true or both
// magnetic), since no declination conversion is performed.
func AngularDistance(a, b Bearing) unit.Angle {
diff := math.Abs(a.Value().Degrees() - b.Value().Degrees())
if diff > 180 {
diff = 360 - diff
}
return unit.Angle(diff) * unit.Degree
}
62 changes: 62 additions & 0 deletions pkg/bearings/distance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package bearings

import (
"testing"

"github.com/martinlindhe/unit"
"github.com/stretchr/testify/assert"
)

func TestAngularDistanceTrueBearings(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
a float64
b float64
wantDeg float64
}{
{"identical", 90, 90, 0},
{"small delta", 10, 15, 5},
{"wrap around due-north", 359, 1, 2},
{"wrap around, swapped inputs", 1, 359, 2},
{"opposite bearings", 0, 180, 180},
{"large delta collapses via wraparound", 10, 350, 20},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
a := NewTrueBearing(unit.Angle(tc.a) * unit.Degree)
b := NewTrueBearing(unit.Angle(tc.b) * unit.Degree)
got := AngularDistance(a, b).Degrees()
assert.InDelta(t, tc.wantDeg, got, 0.01)
})
}
}

func TestAngularDistanceMagneticBearings(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
a float64
b float64
wantDeg float64
}{
{"identical", 90, 90, 0},
{"small delta", 10, 15, 5},
{"wrap around due-north", 359, 1, 2},
{"wrap around, swapped inputs", 1, 359, 2},
{"opposite bearings", 0, 180, 180},
{"large delta collapses via wraparound", 10, 350, 20},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
a := NewMagneticBearing(unit.Angle(tc.a) * unit.Degree)
b := NewMagneticBearing(unit.Angle(tc.b) * unit.Degree)
got := AngularDistance(a, b).Degrees()
assert.InDelta(t, tc.wantDeg, got, 0.01)
})
}
}
15 changes: 13 additions & 2 deletions pkg/controller/threat.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package controller

import (
"context"
"slices"
"sync"
"time"

"github.com/dharmab/skyeye/pkg/brevity"
"github.com/dharmab/skyeye/pkg/parser"
"github.com/rs/zerolog/log"
)

Expand Down Expand Up @@ -95,8 +97,17 @@ func (c *Controller) broadcastThreat(ctx context.Context, hostileGroup brevity.G
logger.Debug().Msg("omitting friendly from threat call because the threat is already merged")
continue
}
if friendly := c.scope.FindUnit(friendID); friendly != nil {
threatCall.Callsigns = c.addFriendlyToBroadcast(threatCall.Callsigns, friendly)
friendly := c.scope.FindUnit(friendID)
if friendly == nil {
continue
}
callsign, ok := parser.ParsePilotCallsign(friendly.Contact.Name)
if !ok {
log.Debug().Str("contact_name", friendly.Contact.Name).Msg("could not parse callsign")
continue
}
if !slices.Contains(threatCall.Callsigns, callsign) {
threatCall.Callsigns = append(threatCall.Callsigns, callsign)
}
}

Expand Down
37 changes: 27 additions & 10 deletions pkg/radar/radar.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/dharmab/skyeye/pkg/encyclopedia"
"github.com/dharmab/skyeye/pkg/encyclopedia/terrains"
"github.com/dharmab/skyeye/pkg/sim"
"github.com/dharmab/skyeye/pkg/simpleradio"
"github.com/dharmab/skyeye/pkg/spatial"
"github.com/dharmab/skyeye/pkg/spatial/projections"
"github.com/dharmab/skyeye/pkg/trackfiles"
Expand Down Expand Up @@ -52,6 +53,14 @@ type Radar struct {
centerLock sync.RWMutex
// mandatoryThreatRadius is the radius within which a hostile aircraft is always considered a threat.
mandatoryThreatRadius unit.Length
// maxSharedBRAABearingSpread is the bearing divergence threshold for merging receivers' BRAAs.
maxSharedBRAABearingSpread unit.Angle
// maxSharedBRAARangeSpread is the range divergence threshold for merging receivers' BRAAs.
maxSharedBRAARangeSpread unit.Length
// srsClient is used to check whether a friendly callsign is on any of the
// controller's SRS frequencies. If nil, every friendly is treated as a
// potential receiver.
srsClient *simpleradio.Client
// completedFades records the IDs of contacts that have been faded.
completedFades map[uint64]time.Time
// completedFadesLock protects completedFades.
Expand All @@ -73,19 +82,27 @@ type Radar struct {
}

// New creates a radar scope that consumes updates from the provided channels.
// maxBRAABearingSpread and maxBRAARangeSpread control the thresholds for merging
// multiple receivers' BRAAs into a single call from their midpoint.
// When enableTerrainDetection is true, SetBullseye will detect the closest DCS terrain and use its
// Transverse Mercator projection for spatial calculations. When false, spherical Earth calculations are used.
func New(coalition coalitions.Coalition, starts <-chan sim.Started, updates <-chan sim.Updated, fades <-chan sim.Faded, mandatoryThreatRadius unit.Length, enableTerrainDetection bool) *Radar {
// srsClient is used by threat detection to filter receiver to friendlies that
// are on the controller's SRS frequencies; pass nil to disable this filtering and
// treat every friendly as a receiver.
func New(coalition coalitions.Coalition, starts <-chan sim.Started, updates <-chan sim.Updated, fades <-chan sim.Faded, mandatoryThreatRadius unit.Length, maxBRAABearingSpread unit.Angle, maxBRAARangeSpread unit.Length, enableTerrainDetection bool, srsClient *simpleradio.Client) *Radar {
return &Radar{
coalition: coalition,
starts: starts,
updates: updates,
fades: fades,
contacts: newContactDatabase(),
mandatoryThreatRadius: mandatoryThreatRadius,
enableTerrainDetection: enableTerrainDetection,
completedFades: map[uint64]time.Time{},
pendingFades: []sim.Faded{},
coalition: coalition,
starts: starts,
updates: updates,
fades: fades,
contacts: newContactDatabase(),
mandatoryThreatRadius: mandatoryThreatRadius,
maxSharedBRAABearingSpread: maxBRAABearingSpread,
maxSharedBRAARangeSpread: maxBRAARangeSpread,
enableTerrainDetection: enableTerrainDetection,
srsClient: srsClient,
completedFades: map[uint64]time.Time{},
pendingFades: []sim.Faded{},
}
}

Expand Down
Loading
Loading