From 304952ef7cb99ec9965b3a95cdad7c809cf87c21 Mon Sep 17 00:00:00 2001 From: Dharma Bellamkonda Date: Mon, 20 Apr 2026 23:05:00 -0600 Subject: [PATCH 1/2] Pick THREAT call format from on-frequency receivers Closes #654. Co-Authored-By: Claude Opus 4.6 --- internal/application/app.go | 10 ++- pkg/bearings/distance.go | 20 +++++ pkg/bearings/distance_test.go | 62 ++++++++++++++ pkg/controller/threat.go | 15 +++- pkg/radar/radar.go | 11 ++- pkg/radar/threat.go | 152 +++++++++++++++++++++++++++++----- pkg/radar/threat_test.go | 104 +++++++++++++++++++++++ 7 files changed, 349 insertions(+), 25 deletions(-) create mode 100644 pkg/bearings/distance.go create mode 100644 pkg/bearings/distance_test.go create mode 100644 pkg/radar/threat_test.go diff --git a/internal/application/app.go b/internal/application/app.go index 7b895d61..aa98c284 100644 --- a/internal/application/app.go +++ b/internal/application/app.go @@ -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.EnableTerrainDetection, radarSRSClient) log.Info().Msg("constructing GCI controller") gciController := controller.New( rdr, diff --git a/pkg/bearings/distance.go b/pkg/bearings/distance.go new file mode 100644 index 00000000..5efc5cce --- /dev/null +++ b/pkg/bearings/distance.go @@ -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 +} diff --git a/pkg/bearings/distance_test.go b/pkg/bearings/distance_test.go new file mode 100644 index 00000000..460e36b8 --- /dev/null +++ b/pkg/bearings/distance_test.go @@ -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) + }) + } +} diff --git a/pkg/controller/threat.go b/pkg/controller/threat.go index fce022fb..30c76206 100644 --- a/pkg/controller/threat.go +++ b/pkg/controller/threat.go @@ -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" ) @@ -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) } } diff --git a/pkg/radar/radar.go b/pkg/radar/radar.go index f7e8a67d..e09d5c94 100644 --- a/pkg/radar/radar.go +++ b/pkg/radar/radar.go @@ -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" @@ -52,6 +53,10 @@ type Radar struct { centerLock sync.RWMutex // mandatoryThreatRadius is the radius within which a hostile aircraft is always considered a threat. mandatoryThreatRadius 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. @@ -75,7 +80,10 @@ type Radar struct { // New creates a radar scope that consumes updates from the provided channels. // 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, enableTerrainDetection bool, srsClient *simpleradio.Client) *Radar { return &Radar{ coalition: coalition, starts: starts, @@ -84,6 +92,7 @@ func New(coalition coalitions.Coalition, starts <-chan sim.Started, updates <-ch contacts: newContactDatabase(), mandatoryThreatRadius: mandatoryThreatRadius, enableTerrainDetection: enableTerrainDetection, + srsClient: srsClient, completedFades: map[uint64]time.Time{}, pendingFades: []sim.Faded{}, } diff --git a/pkg/radar/threat.go b/pkg/radar/threat.go index bd2b944c..5d43ad6c 100644 --- a/pkg/radar/threat.go +++ b/pkg/radar/threat.go @@ -3,13 +3,31 @@ package radar import ( "math" + "github.com/dharmab/skyeye/pkg/bearings" "github.com/dharmab/skyeye/pkg/brevity" "github.com/dharmab/skyeye/pkg/coalitions" "github.com/dharmab/skyeye/pkg/spatial" + "github.com/dharmab/skyeye/pkg/trackfiles" "github.com/martinlindhe/unit" + "github.com/paulmach/orb" + "github.com/paulmach/orb/geo" ) -// Threats returns a map of threat groups of the given coalition to threatened object IDs. +// maxSharedBRAABearingSpread is the bearing divergence threshold between +// receivers' BRAAs to a hostile. If the spread is within this threshold, +// the individual BRAA calls can be merged into a single call using the +// receivers' geographic midpoint. +const maxSharedBRAABearingSpread = 5 * unit.Degree + +// maxSharedBRAARangeSpread is the range divergence threshold between +// receivers' BRAAs to a hostile. If the spread is within this threshold, +// the individual BRAA calls can be merged into a single call using the +// receivers' geographic midpoint. +const maxSharedBRAARangeSpread = 1 * unit.NauticalMile + +// Threats returns a map of hostile groups of the given coalition to the friendly object IDs that +// will hear the threat call. The receiver list is filtered to friendlies that are on the +// controller's SRS frequency, if an SRS client is available. func (r *Radar) Threats(coalition coalitions.Coalition) map[brevity.Group][]uint64 { threats := make(map[*group][]uint64) hostileGroups := r.enumerateGroups(coalition) @@ -28,38 +46,51 @@ func (r *Radar) Threats(coalition coalitions.Coalition) map[brevity.Group][]uint []uint64{}, ) - // Populate threats map with hostile-friendly relations that meet threat criteria. - ids := make([]uint64, 0) + // Collect receiver trackfiles: friendlies inside the hostile's threat radius that are on + // frequency with the controller. These are the pilots who will actually hear the threat + // call. + receivers := make([]*trackfiles.Trackfile, 0) for _, friendlyGroup := range friendlyGroups { distance := spatial.Distance(grp.point(), friendlyGroup.point(), r.withProjection()) withinThreatRadius := r.isGroupWithinThreatRadius(grp, distance) hostileIsHelo := grp.category() == brevity.RotaryWing friendlyIsPlane := friendlyGroup.category() == brevity.FixedWing heloVersusPlane := hostileIsHelo && friendlyIsPlane - if withinThreatRadius && !heloVersusPlane { - ids = append(ids, friendlyGroup.ObjectIDs()...) + if !withinThreatRadius || heloVersusPlane { + continue + } + for _, id := range friendlyGroup.ObjectIDs() { + trackfile, ok := r.contacts.getByID(id) + if !ok { + continue + } + isOnFrequency := r.srsClient != nil && r.srsClient.IsOnFrequency(trackfile.Contact.Name) + if !isOnFrequency { + continue + } + receivers = append(receivers, trackfile) } } - if len(ids) == 0 { + if len(receivers) == 0 { continue } + + ids := make([]uint64, 0, len(receivers)) + for _, tf := range receivers { + ids = append(ids, tf.Contact.ID) + } threats[grp] = ids - // If the hostile group only threatens a single friendly unit, use BRAA instead of Bullseye. - if len(threats[grp]) == 1 { - trackfile, ok := r.contacts.getByID(threats[grp][0]) - if !ok { - continue - } - declination := r.Declination(trackfile.LastKnown().Point) - bearing := spatial.TrueBearing(trackfile.LastKnown().Point, grp.point(), r.withProjection()).Magnetic(declination) - _range := spatial.Distance(trackfile.LastKnown().Point, grp.point(), r.withProjection()) - aspect := brevity.UnknownAspect - if course, ok := grp.course(); ok { - aspect = brevity.AspectFromAngle(bearing, course) - } - grp.braa = brevity.NewBRAA(bearing, _range, grp.altitudes(), aspect) - grp.bullseye = nil + // Pick a call format that best serves the filtered receiver set: + // - 1 receiver → BRAA from that receiver. + // - tightly-grouped receivers → BRAA from the geographic midpoint, usable by all of them. + // - otherwise → bullseye (default from enumerateGroups). + if len(receivers) == 1 { + r.setGroupBRAA(grp, receivers[0].LastKnown().Point) + continue + } + if origin, ok := r.getGroupBRAAOrigin(grp, receivers); ok { + r.setGroupBRAA(grp, origin) } } @@ -70,6 +101,85 @@ func (r *Radar) Threats(coalition coalitions.Coalition) map[brevity.Group][]uint return result } +// setGroupBRAA populates the hostile group's BRAA relative to the given origin point and +// clears its bullseye so the THREAT call renders as BRAA. +func (r *Radar) setGroupBRAA(grp *group, origin orb.Point) { + declination := r.Declination(origin) + bearing := spatial.TrueBearing(origin, grp.point(), r.withProjection()).Magnetic(declination) + _range := spatial.Distance(origin, grp.point(), r.withProjection()) + aspect := brevity.UnknownAspect + if course, ok := grp.course(); ok { + aspect = brevity.AspectFromAngle(bearing, course) + } + grp.braa = brevity.NewBRAA(bearing, _range, grp.altitudes(), aspect) + grp.bullseye = nil +} + +// getGroupBRAAOrigin returns the geographic midpoint of the receivers' positions if their +// BRAAs to the hostile are tightly enough grouped that a BRAA from the midpoint is within an +// acceptable error bound of each receiver's own BRAA. Otherwise it returns false, signalling +// that a bullseye call is more appropriate. +func (r *Radar) getGroupBRAAOrigin(hostile *group, receivers []*trackfiles.Trackfile) (orb.Point, bool) { + if len(receivers) < 2 { + return orb.Point{}, false + } + hostilePoint := hostile.point() + if r.bearingSpread(hostilePoint, receivers) > maxSharedBRAABearingSpread { + return orb.Point{}, false + } + if rangeSpread(hostilePoint, receivers, r.withProjection()) > maxSharedBRAARangeSpread { + return orb.Point{}, false + } + return midpoint(receivers), true +} + +// bearingSpread returns the widest magnetic bearing spread between any two receivers' +// BRAAs to the hostile. +func (r *Radar) bearingSpread(hostile orb.Point, receivers []*trackfiles.Trackfile) unit.Angle { + bearingsToHostile := make([]bearings.Bearing, 0, len(receivers)) + for _, tf := range receivers { + receiver := tf.LastKnown().Point + declination := r.Declination(receiver) + bearing := spatial.TrueBearing(receiver, hostile, r.withProjection()).Magnetic(declination) + bearingsToHostile = append(bearingsToHostile, bearing) + } + widest := unit.Angle(0) + for i, a := range bearingsToHostile { + for _, b := range bearingsToHostile[i+1:] { + if d := bearings.AngularDistance(a, b); d > widest { + widest = d + } + } + } + return widest +} + +// rangeSpread returns the difference between the longest and shortest range among the +// receivers' BRAAs to the hostile. +func rangeSpread(hostile orb.Point, receivers []*trackfiles.Trackfile, opt spatial.Option) unit.Length { + minRange := unit.Length(math.Inf(1)) + maxRange := unit.Length(math.Inf(-1)) + for _, tf := range receivers { + r := spatial.Distance(tf.LastKnown().Point, hostile, opt) + if r < minRange { + minRange = r + } + if r > maxRange { + maxRange = r + } + } + return maxRange - minRange +} + +// midpoint of the given trackfiles. +func midpoint(contacts []*trackfiles.Trackfile) orb.Point { + p := contacts[0].LastKnown().Point + for _, tf := range contacts[1:] { + p = geo.Midpoint(p, tf.LastKnown().Point) + } + return p +} + func (r *Radar) isGroupWithinThreatRadius(grp *group, distance unit.Length) bool { if !grp.isArmed() { return false diff --git a/pkg/radar/threat_test.go b/pkg/radar/threat_test.go new file mode 100644 index 00000000..7d7acc05 --- /dev/null +++ b/pkg/radar/threat_test.go @@ -0,0 +1,104 @@ +package radar + +import ( + "testing" + "time" + + "github.com/dharmab/skyeye/pkg/bearings" + "github.com/dharmab/skyeye/pkg/coalitions" + "github.com/dharmab/skyeye/pkg/spatial" + "github.com/dharmab/skyeye/pkg/trackfiles" + "github.com/martinlindhe/unit" + "github.com/paulmach/orb" + "github.com/stretchr/testify/assert" +) + +// makeReceiverAtOffset builds a trackfile positioned at the given true bearing and distance from +// an origin. The bearing is in degrees and the distance in nautical miles. +func makeReceiverAtOffset(id uint64, origin orb.Point, trueBearingDegrees float64, rangeNM float64) *trackfiles.Trackfile { + bearing := bearings.NewTrueBearing(unit.Angle(trueBearingDegrees) * unit.Degree) + point := spatial.PointAtBearingAndDistance(origin, bearing, unit.Length(rangeNM)*unit.NauticalMile) + return makeTrackfileAt(id, point) +} + +func makeTrackfileAt(id uint64, point orb.Point) *trackfiles.Trackfile { + tf := trackfiles.New(trackfiles.Labels{ + ID: id, + ACMIName: "F-15C", + Name: "Eagle", + Coalition: coalitions.Blue, + }) + tf.Update(trackfiles.Frame{ + Time: time.Now(), + Point: point, + Altitude: 20000 * unit.Foot, + }) + return tf +} + +func TestSharedBRAAOrigin(t *testing.T) { + t.Parallel() + // Place the hostile 40nm due north of a common anchor. Receivers are placed around the + // anchor so we can reason about their BRAAs to the hostile easily. + anchor := orb.Point{-115.0, 36.0} + hostilePoint := spatial.PointAtBearingAndDistance( + anchor, + bearings.NewTrueBearing(0), + 40*unit.NauticalMile, + ) + hostile := &group{contacts: []*trackfiles.Trackfile{makeTrackfileAt(100, hostilePoint)}} + + testCases := []struct { + name string + receivers []*trackfiles.Trackfile + want bool + }{ + { + name: "no receivers", + receivers: nil, + want: false, + }, + { + name: "single receiver", + receivers: []*trackfiles.Trackfile{ + makeTrackfileAt(1, anchor), + }, + want: false, + }, + { + name: "two receivers colocated", + receivers: []*trackfiles.Trackfile{ + makeTrackfileAt(1, anchor), + makeReceiverAtOffset(2, anchor, 90, 0.1), + }, + want: true, + }, + { + name: "bearing spread too wide", + receivers: []*trackfiles.Trackfile{ + makeTrackfileAt(1, anchor), + // Offset east by 10nm — bearing from receiver to hostile shifts by > 5 degrees. + makeReceiverAtOffset(2, anchor, 90, 10), + }, + want: false, + }, + { + name: "range spread too wide", + receivers: []*trackfiles.Trackfile{ + makeTrackfileAt(1, anchor), + // Offset south by 5nm — range to hostile grows by ~5nm, which exceeds 1nm. + makeReceiverAtOffset(2, anchor, 180, 5), + }, + want: false, + }, + } + + r := &Radar{} + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, ok := r.getGroupBRAAOrigin(hostile, tc.receivers) + assert.Equal(t, tc.want, ok) + }) + } +} From e5f9d167e721a99ff0d085ade894fd21223bfdff Mon Sep 17 00:00:00 2001 From: Dharma Bellamkonda Date: Sun, 3 May 2026 22:16:56 -0600 Subject: [PATCH 2/2] Make THREAT call BRAA/bullseye thresholds configurable Add --threat-braa-bearing-spread and --threat-braa-range-spread flags so server admins can tune when threat calls use BRAA vs bullseye format based on their theatre and player density. Co-Authored-By: Claude Opus 4.6 --- cmd/skyeye/main.go | 6 ++++++ config.yaml | 19 +++++++++++++++++++ internal/application/app.go | 2 +- internal/conf/configuration.go | 6 ++++++ pkg/radar/radar.go | 30 +++++++++++++++++++----------- pkg/radar/threat.go | 16 ++-------------- pkg/radar/threat_test.go | 5 ++++- 7 files changed, 57 insertions(+), 27 deletions(-) diff --git a/cmd/skyeye/main.go b/cmd/skyeye/main.go index aae28dc7..6cd89d3d 100644 --- a/cmd/skyeye/main.go +++ b/cmd/skyeye/main.go @@ -71,6 +71,8 @@ var ( threatMonitoringInterval time.Duration threatMonitoringRequiresSRS bool mandatoryThreatRadiusNM float64 + threatBRAABearingSpreadDeg float64 + threatBRAARangeSpreadNM float64 enableTracing bool discordWebhookID string discordWebhookToken string @@ -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 @@ -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, diff --git a/config.yaml b/config.yaml index 76788ceb..0c7a7748 100644 --- a/config.yaml +++ b/config.yaml @@ -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 # diff --git a/internal/application/app.go b/internal/application/app.go index aa98c284..4b0c43b0 100644 --- a/internal/application/app.go +++ b/internal/application/app.go @@ -181,7 +181,7 @@ func NewApplication(config conf.Configuration) (*Application, error) { if config.ThreatMonitoringRequiresSRS { radarSRSClient = srsClient } - rdr := radar.New(config.Coalition, starts, updates, fades, config.MandatoryThreatRadius, config.EnableTerrainDetection, radarSRSClient) + 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, diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 9bfccdbd..711b7f46 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -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 diff --git a/pkg/radar/radar.go b/pkg/radar/radar.go index e09d5c94..a7e6c730 100644 --- a/pkg/radar/radar.go +++ b/pkg/radar/radar.go @@ -53,6 +53,10 @@ 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. @@ -78,23 +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. // 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, enableTerrainDetection bool, srsClient *simpleradio.Client) *Radar { +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, - srsClient: srsClient, - 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{}, } } diff --git a/pkg/radar/threat.go b/pkg/radar/threat.go index 5d43ad6c..a70b6b62 100644 --- a/pkg/radar/threat.go +++ b/pkg/radar/threat.go @@ -13,18 +13,6 @@ import ( "github.com/paulmach/orb/geo" ) -// maxSharedBRAABearingSpread is the bearing divergence threshold between -// receivers' BRAAs to a hostile. If the spread is within this threshold, -// the individual BRAA calls can be merged into a single call using the -// receivers' geographic midpoint. -const maxSharedBRAABearingSpread = 5 * unit.Degree - -// maxSharedBRAARangeSpread is the range divergence threshold between -// receivers' BRAAs to a hostile. If the spread is within this threshold, -// the individual BRAA calls can be merged into a single call using the -// receivers' geographic midpoint. -const maxSharedBRAARangeSpread = 1 * unit.NauticalMile - // Threats returns a map of hostile groups of the given coalition to the friendly object IDs that // will hear the threat call. The receiver list is filtered to friendlies that are on the // controller's SRS frequency, if an SRS client is available. @@ -124,10 +112,10 @@ func (r *Radar) getGroupBRAAOrigin(hostile *group, receivers []*trackfiles.Track return orb.Point{}, false } hostilePoint := hostile.point() - if r.bearingSpread(hostilePoint, receivers) > maxSharedBRAABearingSpread { + if r.bearingSpread(hostilePoint, receivers) > r.maxSharedBRAABearingSpread { return orb.Point{}, false } - if rangeSpread(hostilePoint, receivers, r.withProjection()) > maxSharedBRAARangeSpread { + if rangeSpread(hostilePoint, receivers, r.withProjection()) > r.maxSharedBRAARangeSpread { return orb.Point{}, false } return midpoint(receivers), true diff --git a/pkg/radar/threat_test.go b/pkg/radar/threat_test.go index 7d7acc05..66e47489 100644 --- a/pkg/radar/threat_test.go +++ b/pkg/radar/threat_test.go @@ -93,7 +93,10 @@ func TestSharedBRAAOrigin(t *testing.T) { }, } - r := &Radar{} + r := &Radar{ + maxSharedBRAABearingSpread: 5 * unit.Degree, + maxSharedBRAARangeSpread: 1 * unit.NauticalMile, + } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel()