Repository: dharmab/skyeye (go)

Issue:
Group contacts separated in altitude, using the Altitude STACKS format for stacks greater than 10,000 feet.
Currently, aircraft too far apart from each other in the vertical axis are not grouped together. This is not always correct behavior.

The correct behavior for this is instead to use Altitude STACKS brevity. ref ATP 3-52.4 Chapter IV, Section 5, subsection d.

Implementation notes:

The `radar` package will need to be updated to group contacts with large separations in altitude. Platform groupings will need to be considered - we do want to group different kinds of fighters together because the "Ace Combat Air Force" of mixed platform squadrons is common in DCS, but it probably doesn't make sense to group a Frogfoot with a Flanker. The `encyclopedia` package may need to provide a lookup for grouping aircraft by role.

The `brevity.Group.Altitude` interface will need to report a collection of altitudes rather than a single altitude. This should report only the values used in the Core Information Format rather than the raw values of every contact. For convenience this collection should be ordered by altitude descending.

The composer package will need to be updated to correctly communicate using Altitude STACKS and the HIGH/MEDIUM/LOW fill-ins.

Pull request description:
Implement altitude stacks
Fixes #55.

Groups, BRA and BRAA now stores multiple altitudes. A new type called Stack organizes these altitudes into contact sub-groupings spaced 10,000 feet apart.

Groupings now span an unlimited vertical height, but also now check if the aircraft share one of the tags Fighter, Attack or Unarmed (if defined).

Patch:
diff --git a/pkg/brevity/altitude.go b/pkg/brevity/altitude.go
new file mode 100644
index 0000000..aa4b777
--- /dev/null
+++ b/pkg/brevity/altitude.go
@@ -0,0 +1,48 @@
+package brevity
+
+import (
+	"math"
+	"slices"
+
+	"github.com/martinlindhe/unit"
+)
+
+// Stack represents a single layer of an altitude STACK.
+type Stack struct {
+	Altitude unit.Length
+	Count    int
+}
+
+// Stacks creates altitude STACKS from altitudes.
+func Stacks(a ...unit.Length) []Stack {
+	for i, alt := range a {
+		a[i] = unit.Length(math.Round(alt.Feet()/1000)) * 1000 * unit.Foot
+	}
+	// reverse sort
+	slices.SortFunc(a, func(i, j unit.Length) int {
+		if i < j {
+			return -1
+		}
+		if i > j {
+			return 1
+		}
+		return 0
+	})
+
+	stacks := []Stack{}
+	for i := len(a) - 1; i >= 0; i-- {
+		if len(stacks) == 0 {
+			stacks = append(stacks, Stack{Altitude: a[i], Count: 1})
+		} else {
+			j := len(stacks) - 1
+			highest := stacks[j].Altitude
+			if a[i] <= highest-9900*unit.Foot {
+				stacks = append(stacks, Stack{Altitude: a[i], Count: 1})
+			} else {
+				stacks[j].Count++
+			}
+		}
+	}
+
+	return stacks
+}
diff --git a/pkg/brevity/braa.go b/pkg/brevity/braa.go
index 25d3058..233eed2 100644
--- a/pkg/brevity/braa.go
+++ b/pkg/brevity/braa.go
@@ -24,22 +24,24 @@ type BRA interface {
 	Range() unit.Length
 	// Altitude of the contact above sea level, rounded to the nearest thousands of feet.
 	Altitude() unit.Length
+	// Altitude STACKS of the contact above sea level, rounded to the nearest thousands of feet.
+	Stacks() []Stack
 }
 
 type bra struct {
-	bearing  bearings.Bearing
-	_range   unit.Length
-	altitude unit.Length
+	bearing bearings.Bearing
+	_range  unit.Length
+	stacks  []Stack
 }
 
-func NewBRA(b bearings.Bearing, r unit.Length, a unit.Length) BRA {
+func NewBRA(b bearings.Bearing, r unit.Length, a ...unit.Length) BRA {
 	if !b.IsMagnetic() {
 		log.Warn().Any("bearing", b).Msg("bearing provided to NewBRA should be magnetic")
 	}
 	return &bra{
-		bearing:  b,
-		_range:   r,
-		altitude: a,
+		bearing: b,
+		_range:  r,
+		stacks:  Stacks(a...),
 	}
 }
 
@@ -55,7 +57,15 @@ func (b *bra) Range() unit.Length {
 
 // Altitude implements [BRA.Altitude].
 func (b *bra) Altitude() unit.Length {
-	return unit.Length(math.Round(b.altitude.Feet()/1000)*1000) * unit.Foot
+	if len(b.stacks) == 0 {
+		return 0
+	}
+	return unit.Length(math.Round(b.stacks[0].Altitude.Feet()/1000)) * 1000 * unit.Foot
+}
+
+// Stacks implements [BRA.Stacks].
+func (b *bra) Stacks() []Stack {
+	return b.stacks
 }
 
 type braa struct {
@@ -63,12 +73,12 @@ type braa struct {
 	aspect Aspect
 }
 
-func NewBRAA(b bearings.Bearing, r unit.Length, a unit.Length, aspect Aspect) BRAA {
+func NewBRAA(b bearings.Bearing, r unit.Length, a []unit.Length, aspect Aspect) BRAA {
 	if !b.IsMagnetic() {
 		log.Warn().Any("bearing", b).Msg("bearing provided to NewBRAA should be magnetic")
 	}
 	return &braa{
-		bra:    NewBRA(b, r, a),
+		bra:    NewBRA(b, r, a...),
 		aspect: aspect,
 	}
 }
@@ -88,6 +98,11 @@ func (b *braa) Altitude() unit.Length {
 	return b.bra.Altitude()
 }
 
+// Stacks implements [BRA.Stacks].
+func (b *braa) Stacks() []Stack {
+	return b.bra.Stacks()
+}
+
 // Aspect implements [BRAA.Aspect].
 func (b *braa) Aspect() Aspect {
 	return b.aspect
diff --git a/pkg/brevity/group.go b/pkg/brevity/group.go
index 7079003..94b8e07 100644
--- a/pkg/brevity/group.go
+++ b/pkg/brevity/group.go
@@ -20,8 +20,11 @@ type Group interface {
 	Contacts() int
 	// Bullseye is the location of the group. This may be nil for BOGEY DOPE, SNAPLOCK, and THREAT calls.
 	Bullseye() *Bullseye
-	// Altitude is the group's altitude above sea level. This may be nil for BOGEY DOPE, SNAPLOCK, and THREAT calls.
+	// Altitude is the group's highest altitude. This may be zero for BOGEY DOPE, SNAPLOCK, and THREAT calls.
 	Altitude() unit.Length
+	// Stacks are the group's altitude STACKS, ordered from highest to lowest in intervals of at least 10,000 feet.
+	// This may be empty for BOGEY DOPE, SNAPLOCK, and THREAT calls.
+	Stacks() []Stack
 	// Track is the group's track direction. This may be UnknownDirection for BOGEY DOPE, SNAPLOCK, and THREAT calls.
 	Track() Track
 	// Aspect is the group's aspect angle relative to another aircraft. This may be nil for BOGEY DOPE, SNAPLOCK, and some THREAT calls.
@@ -34,7 +37,7 @@ type Group interface {
 	SetDeclaration(Declaration)
 	// Heavy is true if the group contacts 3 or more contacts.
 	Heavy() bool
-	// Platforms is the NATO reporting names of the group's aircraft platforms (for Soviet/Russian/Chinese aircraft) or
+	// Platforms are the NATO reporting names of the group's aircraft platforms (for Soviet/Russian/Chinese aircraft) or
 	// alternative names for other aircraft. Skyeye supports mixed-platform groups, so this returns multiple values.
 	Platforms() []string
 	// High is true if the aircraft altitude is above 40,000 feet.
@@ -45,5 +48,6 @@ type Group interface {
 	VeryFast() bool
 	// String returns a human-readable description of the group.
 	String() string
+	// UnitIDs returns the unit IDs of all contacts in the group.
 	UnitIDs() []uint32
 }
diff --git a/pkg/composer/group.go b/pkg/composer/group.go
index 8f05c11..efe156b 100644
--- a/pkg/composer/group.go
+++ b/pkg/composer/group.go
@@ -50,9 +50,10 @@ func (c *composer) ComposeGroup(group brevity.Group) NaturalLanguageResponse {
 	}
 
 	// Group location, altitude, and track direction or specific aspect
+	stacks := group.Stacks()
 	if bullseye := group.Bullseye(); bullseye != nil {
 		bullseye := c.ComposeBullseye(*bullseye)
-		altitude := c.ComposeAltitude(group.Altitude(), group.Declaration())
+		altitude := c.ComposeAltitudeStacks(stacks, group.Declaration())
 		speech.WriteString(fmt.Sprintf("%s %s, %s", label, bullseye.Speech, altitude))
 		subtitle.WriteString(fmt.Sprintf("%s %s, %s", label, bullseye.Subtitle, altitude))
 		if group.Track() != brevity.UnknownDirection {
@@ -82,6 +83,12 @@ func (c *composer) ComposeGroup(group brevity.Group) NaturalLanguageResponse {
 	subtitle.WriteString(contacts.Subtitle)
 	speech.WriteString(contacts.Speech)
 
+	if !group.High() {
+		if len(stacks) > 1 {
+			writeBoth(fmt.Sprintf(", %s", c.ComposeAltitudeFillIns(stacks)))
+		}
+	}
+
 	// Platform
 	if len(group.Platforms()) > 0 {
 		writeBoth(", ")
@@ -122,6 +129,34 @@ func (c *composer) ComposeContacts(n int) NaturalLanguageResponse {
 	}
 }
 
+func (c *composer) ComposeAltitudeStacks(stacks []brevity.Stack, declaration brevity.Declaration) string {
+	if len(stacks) == 0 {
+		return "altitude unknown"
+	}
+
+	if len(stacks) == 1 {
+		return c.ComposeAltitude(stacks[0].Altitude, declaration)
+	}
+
+	s := fmt.Sprintf("stack %s", c.ComposeAltitude(stacks[0].Altitude, declaration))
+	for i := 1; i < len(stacks)-1; i++ {
+		s += fmt.Sprintf(", %s", c.ComposeAltitude(stacks[i].Altitude, declaration))
+	}
+	s += fmt.Sprintf(", and %s", c.ComposeAltitude(stacks[len(stacks)-1].Altitude, declaration))
+	return s
+}
+
+func (c *composer) ComposeAltitudeFillIns(stacks []brevity.Stack) string {
+	if len(stacks) == 2 {
+		return fmt.Sprintf("%d high, %d low", stacks[0].Count, stacks[1].Count)
+	}
+
+	if len(stacks) == 3 {
+		return fmt.Sprintf("%d high, %d medium, %d low", stacks[0].Count, stacks[1].Count, stacks[2].Count)
+	}
+	return ""
+}
+
 func (c *composer) ComposeAltitude(altitude unit.Length, declaration brevity.Declaration) string {
 	if altitude.Meters() < 100 {
 		return "altitude unknown"
diff --git a/pkg/radar/group.go b/pkg/radar/group.go
index 058af06..803c7a7 100644
--- a/pkg/radar/group.go
+++ b/pkg/radar/group.go
@@ -2,7 +2,6 @@ package radar
 
 import (
 	"fmt"
-	"math"
 	"strings"
 	"time"
 
@@ -74,15 +73,29 @@ func (g *group) Bullseye() *brevity.Bullseye {
 	return brevity.NewBullseye(bearing.Magnetic(declination), distance)
 }
 
-// Altitude implements [brevity.Group.Altitude] by averaging the altitudes of all contacts in the group
-func (g *group) Altitude() unit.Length {
-	var sum unit.Length
+func (g *group) Stacks() []brevity.Stack {
+	altitudes := []unit.Length{}
 	for _, trackfile := range g.contacts {
-		sum += trackfile.Track.Front().Altitude
+		altitudes = append(altitudes, trackfile.LastKnown().Altitude)
+	}
+	return brevity.Stacks(altitudes...)
+}
+
+func (g *group) Altitude() unit.Length {
+	stacks := g.Stacks()
+	if len(stacks) == 0 {
+		return 0
+	}
+	return stacks[0].Altitude
+}
+
+func (g *group) altitudes() []unit.Length {
+	stacks := g.Stacks()
+	altitudes := make([]unit.Length, 0, len(stacks))
+	for _, stack := range stacks {
+		altitudes = append(altitudes, stack.Altitude)
 	}
-	mean := sum / unit.Length(len(g.contacts))
-	rounded := unit.Length((math.Round(mean.Feet()/1000) * 1000)) * unit.Foot
-	return rounded
+	return altitudes
 }
 
 // Track implements [brevity.Group.Track]
diff --git a/pkg/radar/grouping.go b/pkg/radar/grouping.go
index ed37cbc..012eea7 100644
--- a/pkg/radar/grouping.go
+++ b/pkg/radar/grouping.go
@@ -1,10 +1,10 @@
 package radar
 
 import (
-	"math"
 	"slices"
 
 	"github.com/dharmab/skyeye/pkg/coalitions"
+	"github.com/dharmab/skyeye/pkg/encyclopedia"
 	"github.com/dharmab/skyeye/pkg/trackfiles"
 	"github.com/martinlindhe/unit"
 	"github.com/paulmach/orb/geo"
@@ -57,14 +57,24 @@ func (s *scope) findGroupForAircraft(trackfile *trackfiles.Trackfile) *group {
 
 // addNearbyAircraftToGroup recursively adds all nearby aircraft which:
 //   - are of the same coalition
-//   - are within 3 nautical miles in 2D distance of each other
-//   - are within 3000 feet in altitude of each other
+//   - are within 5 nautical miles in 2D distance of each other
+//   - have similar tags
 //
-// These are increased from the ATP numbers beacause the DCS AI isn't amazing at holding formation.
+// The spread is increased from the ATP numbers beacause the DCS AI isn't amazing at holding formation.
 // We allow mixed platform groups because these are fairly common in DCS.
 func (s *scope) addNearbyAircraftToGroup(this *trackfiles.Trackfile, group *group) {
+	var tag encyclopedia.AircraftTag
+	thisData, ok := encyclopedia.GetAircraftData(this.Contact.ACMIName)
+	if ok {
+		if thisData.HasTag(encyclopedia.Fighter) {
+			tag = encyclopedia.Fighter
+		} else if thisData.HasTag(encyclopedia.Attack) {
+			tag = encyclopedia.Attack
+		} else if thisData.HasTag(encyclopedia.Unarmed) {
+			tag = encyclopedia.Unarmed
+		}
+	}
 	spreadInterval := 5 * unit.NauticalMile
-	stackInterval := 3000 * unit.Foot
 	itr := s.contacts.itr()
 	for itr.next() {
 		other := itr.value()
@@ -78,16 +88,30 @@ func (s *scope) addNearbyAircraftToGroup(this *trackfiles.Trackfile, group *grou
 			continue
 		}
 
+		// Check coalition, categoty, and filters
 		if !s.isMatch(other, this.Contact.Coalition, group.category()) {
 			continue
 		}
 
-		isWithinSpread := geo.Distance(other.LastKnown().Point, this.LastKnown().Point) < spreadInterval.Meters()
-		isWithinStack := math.Abs(other.LastKnown().Altitude.Feet()-this.LastKnown().Altitude.Feet()) < stackInterval.Feet()
+		// Check tag similarity
+		subCategories := []encyclopedia.AircraftTag{encyclopedia.Fighter, encyclopedia.Attack, encyclopedia.Unarmed}
+		if slices.Contains(subCategories, tag) {
+			data, ok := encyclopedia.GetAircraftData(other.Contact.ACMIName)
+			if !ok {
+				continue
+			}
+			if !data.HasTag(tag) {
+				continue
+			}
+		}
 
-		if isWithinSpread && isWithinStack {
-			group.contacts = append(group.contacts, other)
-			s.addNearbyAircraftToGroup(other, group)
+		// Check spread distance
+		isWithinSpread := geo.Distance(other.LastKnown().Point, this.LastKnown().Point) < spreadInterval.Meters()
+		if !isWithinSpread {
+			continue
 		}
+
+		group.contacts = append(group.contacts, other)
+		s.addNearbyAircraftToGroup(other, group)
 	}
 }
diff --git a/pkg/radar/nearby.go b/pkg/radar/nearby.go
index c20ad8e..a0d1a5a 100644
--- a/pkg/radar/nearby.go
+++ b/pkg/radar/nearby.go
@@ -48,9 +48,8 @@ func (s *scope) FindNearbyGroupsWithBRAA(origin, interest orb.Point, minAltitude
 			) * unit.Degree,
 		).Magnetic(s.Declination(origin))
 		_range := unit.Length(math.Abs(geo.Distance(origin, grp.point())))
-		altitude := grp.Altitude()
 		aspect := brevity.AspectFromAngle(bearing, grp.course())
-		grp.braa = brevity.NewBRAA(bearing, _range, altitude, aspect)
+		grp.braa = brevity.NewBRAA(bearing, _range, grp.altitudes(), aspect)
 		grp.bullseye = nil
 
 		result = append(result, grp)
diff --git a/pkg/radar/nearest.go b/pkg/radar/nearest.go
index 04749ae..3da47be 100644
--- a/pkg/radar/nearest.go
+++ b/pkg/radar/nearest.go
@@ -80,12 +80,11 @@ func (s *scope) FindNearestGroupWithBRAA(
 		) * unit.Degree,
 	).Magnetic(declination)
 	_range := unit.Length(geo.Distance(origin, grp.point())) * unit.Meter
-	altitude := trackfile.LastKnown().Altitude
 	aspect := brevity.AspectFromAngle(bearing, trackfile.Course())
 	grp.braa = brevity.NewBRAA(
 		bearing,
 		_range,
-		altitude,
+		grp.altitudes(),
 		aspect,
 	)
 	grp.bullseye = nil
@@ -178,7 +177,7 @@ func (s *scope) FindNearestGroupInSector(origin orb.Point, minAltitude, maxAltit
 	grp.braa = brevity.NewBRAA(
 		preciseBearing,
 		_range,
-		grp.Altitude(),
+		grp.altitudes(),
 		grp.Aspect(),
 	)
 	logger.Debug().Str("group", grp.String()).Msg("determined nearest group")
diff --git a/pkg/radar/threat.go b/pkg/radar/threat.go
index 392255d..d87fe98 100644
--- a/pkg/radar/threat.go
+++ b/pkg/radar/threat.go
@@ -51,7 +51,7 @@ func (s *scope) Threats(coalition coalitions.Coalition) map[brevity.Group][]uint
 			).Magnetic(s.Declination(trackfile.LastKnown().Point))
 			_range := unit.Length(geo.Distance(trackfile.LastKnown().Point, grp.point()))
 			aspect := brevity.AspectFromAngle(bearing, grp.course())
-			grp.braa = brevity.NewBRAA(bearing, _range, grp.Altitude(), aspect)
+			grp.braa = brevity.NewBRAA(bearing, _range, grp.altitudes(), aspect)
 			grp.bullseye = nil
 		}
 	}


Test patch:
diff --git a/pkg/brevity/altitude_test.go b/pkg/brevity/altitude_test.go
new file mode 100644
index 0000000..f13cb8b
--- /dev/null
+++ b/pkg/brevity/altitude_test.go
@@ -0,0 +1,46 @@
+package brevity
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/martinlindhe/unit"
+	"github.com/rs/zerolog/log"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestStacks(t *testing.T) {
+	tests := []struct {
+		input    []unit.Length
+		expected []Stack
+	}{
+		{
+			input:    []unit.Length{unit.Foot * 10000, unit.Foot * 20000, unit.Foot * 30000, unit.Foot * 40000, unit.Foot * 50000},
+			expected: []Stack{{Altitude: unit.Foot * 50000, Count: 1}, {Altitude: unit.Foot * 40000, Count: 1}, {Altitude: unit.Foot * 30000, Count: 1}, {Altitude: unit.Foot * 20000, Count: 1}, {Altitude: unit.Foot * 10000, Count: 1}},
+		},
+		{
+			input:    []unit.Length{unit.Foot * 10000, unit.Foot * 10000},
+			expected: []Stack{{Altitude: unit.Foot * 10000, Count: 2}},
+		},
+		{
+			input:    []unit.Length{unit.Foot * 10000, unit.Foot * 15000},
+			expected: []Stack{{Altitude: unit.Foot * 15000, Count: 2}},
+		},
+		{
+			input:    []unit.Length{unit.Foot * 10000, unit.Foot * 20000, unit.Foot * 20000},
+			expected: []Stack{{Altitude: unit.Foot * 20000, Count: 2}, {Altitude: unit.Foot * 10000, Count: 1}},
+		},
+	}
+	for i, test := range tests {
+		t.Run(fmt.Sprint(i), func(t *testing.T) {
+			stacks := Stacks(test.input...)
+			for i, stack := range stacks {
+				expectedStack := test.expected[i]
+				log.Trace().Float64("expected", float64(expectedStack.Altitude)).Float64("actual", float64(stack.Altitude)).Msg("checking altitudes")
+				assert.Equal(t, expectedStack.Altitude, stack.Altitude)
+				log.Trace().Int("expected", expectedStack.Count).Int("actual", stack.Count).Msg("checking counts")
+				assert.Equal(t, expectedStack.Count, stack.Count)
+			}
+		})
+	}
+}