Skip to content

Commit 0937053

Browse files
dharmabclaude
andauthored
Pick THREAT call format from on-frequency receivers (#656)
Closes #654. ## Summary - `radar.Threats` now filters the receiver set to friendlies on the controller's SRS frequency before deciding the call format, so AI wingmen and off-frequency friendlies no longer inflate the count. - BRAA or bulleye is used based on the receivers: 1 receiver → BRAA from that receiver; 2+ receivers within ≤5° bearing spread and ≤1 nm range spread of each other's BRAAs → BRAA from the geographic midpoint; otherwise bullseye. - `radar.New` takes a `*simpleradio.Client` for checking who's on frequency. - `broadcastThreat` trusts the filtered map and no longer checks frequency. - New helper `bearings.AngularDistance` for wrap-around-safe bearing differences. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 193cce1 commit 0937053

10 files changed

Lines changed: 388 additions & 34 deletions

File tree

cmd/skyeye/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ var (
7171
threatMonitoringInterval time.Duration
7272
threatMonitoringRequiresSRS bool
7373
mandatoryThreatRadiusNM float64
74+
threatBRAABearingSpreadDeg float64
75+
threatBRAARangeSpreadNM float64
7476
enableTracing bool
7577
discordWebhookID string
7678
discordWebhookToken string
@@ -151,6 +153,8 @@ func init() {
151153
skyeye.Flags().BoolVar(&enableThreatMonitoring, "threat-monitoring", true, "Enable THREAT monitoring")
152154
skyeye.Flags().DurationVar(&threatMonitoringInterval, "threat-monitoring-interval", 3*time.Minute, "How often to broadcast THREAT")
153155
skyeye.Flags().Float64Var(&mandatoryThreatRadiusNM, "mandatory-threat-radius", 25, "Briefed radius for mandatory THREAT calls, in nautical miles")
156+
skyeye.Flags().Float64Var(&threatBRAABearingSpreadDeg, "threat-braa-bearing-spread", 5, "Bearing spread threshold for THREAT call BRAA-vs-bullseye decision, in degrees")
157+
skyeye.Flags().Float64Var(&threatBRAARangeSpreadNM, "threat-braa-range-spread", 1, "Range spread threshold for THREAT call BRAA-vs-bullseye decision, in nautical miles")
154158
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")
155159

156160
// Tracing
@@ -415,6 +419,8 @@ func run(_ *cobra.Command, _ []string) {
415419
ThreatMonitoringInterval: threatMonitoringInterval,
416420
ThreatMonitoringRequiresSRS: threatMonitoringRequiresSRS,
417421
MandatoryThreatRadius: unit.Length(mandatoryThreatRadiusNM) * unit.NauticalMile,
422+
ThreatBRAABearingSpread: unit.Angle(threatBRAABearingSpreadDeg) * unit.Degree,
423+
ThreatBRAARangeSpread: unit.Length(threatBRAARangeSpreadNM) * unit.NauticalMile,
418424
EnableTracing: enableTracing,
419425
DiscordWebhookID: discordWebhookID,
420426
DiscorbWebhookToken: discordWebhookToken,

config.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,25 @@
191191
# miles) is a reasonable choice for a modern setting, but you may wish to tune
192192
# this based on mission requirements and player skill level.
193193
#mandatory-threat-radius: 25
194+
#
195+
# The GCI may issue threat calls using either BRAA or Bullseye format.
196+
# - If there is only one friendly aircraft in the area and on the controller's
197+
# frequency, the call always uses BRAA format.
198+
# - If there are multiple friendlies in the area and on frequency, the call
199+
# uses BRAA format if the friendlies are close together or Bullseye format
200+
# if the friendlies are split further apart.
201+
# These two thresholds control how close together the friendlies need to be
202+
# for the GCI to use BRAA instead of Bullseye.
203+
#
204+
# The bearing spread is the maximum difference in bearing (degrees) from each
205+
# friendly to the hostile. If the bearings diverge more than this, the GCI
206+
# uses Bullseye.
207+
#threat-braa-bearing-spread: 5
208+
#
209+
# The range spread is the maximum difference in range (nautical miles) from
210+
# each friendly to the hostile. If the ranges diverge more than this, the GCI
211+
# uses Bullseye.
212+
#threat-braa-range-spread: 1
194213

195214
# LOGGING
196215
#

internal/application/app.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,15 @@ func NewApplication(config conf.Configuration) (*Application, error) {
173173

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

176-
rdr := radar.New(config.Coalition, starts, updates, fades, config.MandatoryThreatRadius, config.EnableTerrainDetection)
176+
// When threat monitoring requires SRS, the radar uses the SRS client to restrict the
177+
// receiver set of each threat call to friendlies on frequency, so the BRAA/bullseye format
178+
// reflects the pilots who will hear the call. When SRS is not required (debugging), leaving
179+
// the client nil tells the radar to treat every friendly as a potential receiver.
180+
var radarSRSClient *simpleradio.Client // separate variable so we can pass nil to the radar without affecting other references to srsClient
181+
if config.ThreatMonitoringRequiresSRS {
182+
radarSRSClient = srsClient
183+
}
184+
rdr := radar.New(config.Coalition, starts, updates, fades, config.MandatoryThreatRadius, config.ThreatBRAABearingSpread, config.ThreatBRAARangeSpread, config.EnableTerrainDetection, radarSRSClient)
177185
log.Info().Msg("constructing GCI controller")
178186
gciController := controller.New(
179187
rdr,

internal/conf/configuration.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ type Configuration struct {
9090
ThreatMonitoringInterval time.Duration
9191
// MandatoryThreatRadius is the brief range at which a THREAT call is mandatory.
9292
MandatoryThreatRadius unit.Length
93+
// ThreatBRAABearingSpread is the maximum bearing divergence between receivers' BRAAs to a hostile
94+
// before falling back to a bullseye call.
95+
ThreatBRAABearingSpread unit.Angle
96+
// ThreatBRAARangeSpread is the maximum range divergence between receivers' BRAAs to a hostile
97+
// before falling back to a bullseye call.
98+
ThreatBRAARangeSpread unit.Length
9399
// ThreatMonitoringRequiresSRS controls whether threat calls are issued to aircraft that are not on an SRS frequency. This is mostly
94100
// for debugging.
95101
ThreatMonitoringRequiresSRS bool

pkg/bearings/distance.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package bearings
2+
3+
import (
4+
"math"
5+
6+
"github.com/martinlindhe/unit"
7+
)
8+
9+
// AngularDistance returns the smallest angular difference between two bearings, accounting for
10+
// wrap-around (e.g. 001° and 359° are 2° apart, not 358°). The returned angle is always in the
11+
// range [0°, 180°]. Both bearings are compared by their normalized value; the caller is
12+
// responsible for ensuring they are expressed in the same reference frame (both true or both
13+
// magnetic), since no declination conversion is performed.
14+
func AngularDistance(a, b Bearing) unit.Angle {
15+
diff := math.Abs(a.Value().Degrees() - b.Value().Degrees())
16+
if diff > 180 {
17+
diff = 360 - diff
18+
}
19+
return unit.Angle(diff) * unit.Degree
20+
}

pkg/bearings/distance_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package bearings
2+
3+
import (
4+
"testing"
5+
6+
"github.com/martinlindhe/unit"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestAngularDistanceTrueBearings(t *testing.T) {
11+
t.Parallel()
12+
testCases := []struct {
13+
name string
14+
a float64
15+
b float64
16+
wantDeg float64
17+
}{
18+
{"identical", 90, 90, 0},
19+
{"small delta", 10, 15, 5},
20+
{"wrap around due-north", 359, 1, 2},
21+
{"wrap around, swapped inputs", 1, 359, 2},
22+
{"opposite bearings", 0, 180, 180},
23+
{"large delta collapses via wraparound", 10, 350, 20},
24+
}
25+
26+
for _, tc := range testCases {
27+
t.Run(tc.name, func(t *testing.T) {
28+
t.Parallel()
29+
a := NewTrueBearing(unit.Angle(tc.a) * unit.Degree)
30+
b := NewTrueBearing(unit.Angle(tc.b) * unit.Degree)
31+
got := AngularDistance(a, b).Degrees()
32+
assert.InDelta(t, tc.wantDeg, got, 0.01)
33+
})
34+
}
35+
}
36+
37+
func TestAngularDistanceMagneticBearings(t *testing.T) {
38+
t.Parallel()
39+
testCases := []struct {
40+
name string
41+
a float64
42+
b float64
43+
wantDeg float64
44+
}{
45+
{"identical", 90, 90, 0},
46+
{"small delta", 10, 15, 5},
47+
{"wrap around due-north", 359, 1, 2},
48+
{"wrap around, swapped inputs", 1, 359, 2},
49+
{"opposite bearings", 0, 180, 180},
50+
{"large delta collapses via wraparound", 10, 350, 20},
51+
}
52+
53+
for _, tc := range testCases {
54+
t.Run(tc.name, func(t *testing.T) {
55+
t.Parallel()
56+
a := NewMagneticBearing(unit.Angle(tc.a) * unit.Degree)
57+
b := NewMagneticBearing(unit.Angle(tc.b) * unit.Degree)
58+
got := AngularDistance(a, b).Degrees()
59+
assert.InDelta(t, tc.wantDeg, got, 0.01)
60+
})
61+
}
62+
}

pkg/controller/threat.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package controller
22

33
import (
44
"context"
5+
"slices"
56
"sync"
67
"time"
78

89
"github.com/dharmab/skyeye/pkg/brevity"
10+
"github.com/dharmab/skyeye/pkg/parser"
911
"github.com/rs/zerolog/log"
1012
)
1113

@@ -95,8 +97,17 @@ func (c *Controller) broadcastThreat(ctx context.Context, hostileGroup brevity.G
9597
logger.Debug().Msg("omitting friendly from threat call because the threat is already merged")
9698
continue
9799
}
98-
if friendly := c.scope.FindUnit(friendID); friendly != nil {
99-
threatCall.Callsigns = c.addFriendlyToBroadcast(threatCall.Callsigns, friendly)
100+
friendly := c.scope.FindUnit(friendID)
101+
if friendly == nil {
102+
continue
103+
}
104+
callsign, ok := parser.ParsePilotCallsign(friendly.Contact.Name)
105+
if !ok {
106+
log.Debug().Str("contact_name", friendly.Contact.Name).Msg("could not parse callsign")
107+
continue
108+
}
109+
if !slices.Contains(threatCall.Callsigns, callsign) {
110+
threatCall.Callsigns = append(threatCall.Callsigns, callsign)
100111
}
101112
}
102113

pkg/radar/radar.go

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/dharmab/skyeye/pkg/encyclopedia"
1313
"github.com/dharmab/skyeye/pkg/encyclopedia/terrains"
1414
"github.com/dharmab/skyeye/pkg/sim"
15+
"github.com/dharmab/skyeye/pkg/simpleradio"
1516
"github.com/dharmab/skyeye/pkg/spatial"
1617
"github.com/dharmab/skyeye/pkg/spatial/projections"
1718
"github.com/dharmab/skyeye/pkg/trackfiles"
@@ -52,6 +53,14 @@ type Radar struct {
5253
centerLock sync.RWMutex
5354
// mandatoryThreatRadius is the radius within which a hostile aircraft is always considered a threat.
5455
mandatoryThreatRadius unit.Length
56+
// maxSharedBRAABearingSpread is the bearing divergence threshold for merging receivers' BRAAs.
57+
maxSharedBRAABearingSpread unit.Angle
58+
// maxSharedBRAARangeSpread is the range divergence threshold for merging receivers' BRAAs.
59+
maxSharedBRAARangeSpread unit.Length
60+
// srsClient is used to check whether a friendly callsign is on any of the
61+
// controller's SRS frequencies. If nil, every friendly is treated as a
62+
// potential receiver.
63+
srsClient *simpleradio.Client
5564
// completedFades records the IDs of contacts that have been faded.
5665
completedFades map[uint64]time.Time
5766
// completedFadesLock protects completedFades.
@@ -73,19 +82,27 @@ type Radar struct {
7382
}
7483

7584
// New creates a radar scope that consumes updates from the provided channels.
85+
// maxBRAABearingSpread and maxBRAARangeSpread control the thresholds for merging
86+
// multiple receivers' BRAAs into a single call from their midpoint.
7687
// When enableTerrainDetection is true, SetBullseye will detect the closest DCS terrain and use its
7788
// Transverse Mercator projection for spatial calculations. When false, spherical Earth calculations are used.
78-
func New(coalition coalitions.Coalition, starts <-chan sim.Started, updates <-chan sim.Updated, fades <-chan sim.Faded, mandatoryThreatRadius unit.Length, enableTerrainDetection bool) *Radar {
89+
// srsClient is used by threat detection to filter receiver to friendlies that
90+
// are on the controller's SRS frequencies; pass nil to disable this filtering and
91+
// treat every friendly as a receiver.
92+
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 {
7993
return &Radar{
80-
coalition: coalition,
81-
starts: starts,
82-
updates: updates,
83-
fades: fades,
84-
contacts: newContactDatabase(),
85-
mandatoryThreatRadius: mandatoryThreatRadius,
86-
enableTerrainDetection: enableTerrainDetection,
87-
completedFades: map[uint64]time.Time{},
88-
pendingFades: []sim.Faded{},
94+
coalition: coalition,
95+
starts: starts,
96+
updates: updates,
97+
fades: fades,
98+
contacts: newContactDatabase(),
99+
mandatoryThreatRadius: mandatoryThreatRadius,
100+
maxSharedBRAABearingSpread: maxBRAABearingSpread,
101+
maxSharedBRAARangeSpread: maxBRAARangeSpread,
102+
enableTerrainDetection: enableTerrainDetection,
103+
srsClient: srsClient,
104+
completedFades: map[uint64]time.Time{},
105+
pendingFades: []sim.Faded{},
89106
}
90107
}
91108

0 commit comments

Comments
 (0)