Skip to content
Open
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
13 changes: 9 additions & 4 deletions aviation/intent.go
Original file line number Diff line number Diff line change
Expand Up @@ -889,16 +889,18 @@ func (a ATISIntent) Render(rt *RadioTransmission, r *rand.Rand) {
type TrafficAdvisoryResponse int

const (
TrafficResponseIMC TrafficAdvisoryResponse = iota // In IMC, can't see traffic
TrafficResponseLooking // No traffic visible, will look
TrafficResponseTrafficSeen // Traffic is in sight
TrafficResponseAcknowledged // Other traffic is maintaining visual separation
TrafficResponseIMC TrafficAdvisoryResponse = iota // In IMC, can't see traffic
TrafficResponseLooking // No traffic visible, will look
TrafficResponseTrafficSeen // Traffic is in sight
TrafficResponseAcknowledged // Other traffic is maintaining visual separation
TrafficResponseWrongQuadrant // Pilot sees traffic, but at a different o'clock than reported
)

// TrafficAdvisoryIntent represents a pilot's response to a traffic advisory
type TrafficAdvisoryIntent struct {
Response TrafficAdvisoryResponse
WillMaintainSeparation bool // If true, add "will maintain visual separation"
ActualOclock int // For TrafficResponseWrongQuadrant: the o'clock at which the pilot actually sees the traffic
}

func (t TrafficAdvisoryIntent) Render(rt *RadioTransmission, r *rand.Rand) {
Expand All @@ -915,6 +917,9 @@ func (t TrafficAdvisoryIntent) Render(rt *RadioTransmission, r *rand.Rand) {
}
case TrafficResponseAcknowledged:
rt.Add("[roger|copy the traffic|roger, we have the traffic]")
case TrafficResponseWrongQuadrant:
rt.Add("[traffic at our {num} o'clock in sight|we have traffic at our {num} o'clock]",
t.ActualOclock)
}
}

Expand Down
5 changes: 3 additions & 2 deletions sim/aircraft.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ type SeenAircraft struct {
}

type UnseenTrafficCall struct {
Callsign av.ADSBCallsign
CalledTime Time
Callsign av.ADSBCallsign
CalledTime Time
WhereAskFireTime Time // If non-zero and passed, pilot proactively asks "where's that traffic"
}

type Aircraft struct {
Expand Down
134 changes: 104 additions & 30 deletions sim/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -490,39 +490,27 @@ func (s *Sim) handleTrafficAdvisory(ac *Aircraft, oclock int, miles int, traffic
return av.TrafficAdvisoryIntent{Response: av.TrafficResponseIMC}
}

// Convert o'clock to heading offset from aircraft heading
// 12 o'clock = 0 degrees, 3 o'clock = 90 degrees, etc.
oclockHeading := math.MagneticHeading((oclock % 12) * 30) // 0, 30, 60, 90... 330
trafficHeading := math.NormalizeHeading(ac.Heading() + oclockHeading)
kind, trafficFound, actualOclock := s.findAdvisoryTraffic(ac, oclock, miles, trafficAltFeet)

// Calculate the approximate position of the reported traffic
nmPerLong := ac.NmPerLongitude()
magVar := ac.MagneticVariation()
trafficPos := math.Offset2LL(ac.Position(), math.MagneticToTrue(trafficHeading, magVar), float32(miles), nmPerLong)

// Search for actual traffic near the reported position
// Tolerance: +/- 2 miles horizontal, +/- 1000 feet vertical
const horizontalToleranceNM = 2.0
const verticalToleranceFeet = 1000.0

var trafficFound av.ADSBCallsign
trafficDist := float32(999999)
for cs, other := range s.Aircraft {
if cs == ac.ADSBCallsign {
continue // Skip self
switch kind {
case advisoryMatchWrongQuadrant:
// Real traffic nearby but at a different o'clock than reported — pilot
// reports it at the corrected clock position.
sighting := ac.RecordSighting(trafficFound, s.State.SimTime)
sighting.OfferedToMaintainSeparation = false
ac.clearUnseenTrafficCall()
return av.TrafficAdvisoryIntent{
Response: av.TrafficResponseWrongQuadrant,
ActualOclock: actualOclock,
}

dist := math.NMDistance2LL(trafficPos, other.Position())
altDiff := math.Abs(other.Altitude() - trafficAltFeet)

if dist <= horizontalToleranceNM && altDiff <= verticalToleranceFeet && dist < trafficDist {
trafficFound = cs
trafficDist = dist
case advisoryMatchNone:
// No traffic anywhere nearby — respond "looking" and schedule the
// proactive "where's that traffic" follow-up.
ac.UnseenTrafficCall = &UnseenTrafficCall{
CalledTime: s.State.SimTime,
WhereAskFireTime: s.State.SimTime.Add(s.Rand.DurationRange(30*time.Second, 60*time.Second)),
}
}

if trafficFound == "" {
// No traffic found - respond "looking"
return av.TrafficAdvisoryIntent{Response: av.TrafficResponseLooking}
}

Expand Down Expand Up @@ -557,11 +545,97 @@ func (s *Sim) handleTrafficAdvisory(ac *Aircraft, oclock int, miles int, traffic
}
}

// "Looking" - schedule possible delayed traffic-in-sight call
// "Looking" - schedule possible delayed traffic-in-sight call, plus a
// longer "where's that traffic" follow-up if they never spot it.
s.enqueueFutureTrafficInSight(ac.ADSBCallsign, trafficFound)
ac.UnseenTrafficCall.WhereAskFireTime = s.State.SimTime.Add(s.Rand.DurationRange(30*time.Second, 60*time.Second))
return av.TrafficAdvisoryIntent{Response: av.TrafficResponseLooking}
}

type advisoryMatchKind int

const (
advisoryMatchNone advisoryMatchKind = iota // no plausible traffic anywhere nearby
advisoryMatchExact // traffic at the reported point (within 2 NM, 1000 ft)
advisoryMatchWrongQuadrant // traffic nearby but in a different o'clock (within 15°-60° of the reported bearing)
)

// findAdvisoryTraffic walks s.Aircraft once looking for traffic that matches
// the controller-issued advisory. Returns:
// - advisoryMatchExact when the closest target within ±2 NM and ±1000 ft of
// the reported point qualifies.
// - advisoryMatchWrongQuadrant when no exact match exists but the closest
// target within (5 + 0.5*miles) NM and ±1000 ft sits at a bearing 15-60°
// off the reported one. Returns the actual o'clock the pilot sees.
// - advisoryMatchNone otherwise.
func (s *Sim) findAdvisoryTraffic(ac *Aircraft, reportedOclock, reportedMiles int, trafficAltFeet float32) (advisoryMatchKind, av.ADSBCallsign, int) {
const horizontalToleranceNM = 2.0
const verticalToleranceFeet = 1000.0
const wqMinAngle = 15.0 // below this, treat as exact (same o'clock bin)
const wqMaxAngle = 60.0 // above this, too far off to be "that" traffic
wqMaxRangeNM := float32(5) + 0.5*float32(reportedMiles)

nmPerLong := ac.NmPerLongitude()
magVar := ac.MagneticVariation()

// Reported bearing from ac (true), and the corresponding target point.
reportedRelHeading := math.MagneticHeading((reportedOclock % 12) * 30)
reportedHeadingMag := math.NormalizeHeading(ac.Heading() + reportedRelHeading)
reportedHeadingTrue := math.MagneticToTrue(reportedHeadingMag, magVar)
reportedPos := math.Offset2LL(ac.Position(), reportedHeadingTrue, float32(reportedMiles), nmPerLong)

var (
exactCS av.ADSBCallsign
exactDist = float32(999999)
wqCS av.ADSBCallsign
wqDist = float32(999999)
)

for cs, other := range s.Aircraft {
if cs == ac.ADSBCallsign {
continue
}
if math.Abs(other.Altitude()-trafficAltFeet) > verticalToleranceFeet {
continue
}

// Exact match: close to the reported point.
distFromReported := math.NMDistance2LL(reportedPos, other.Position())
if distFromReported <= horizontalToleranceNM && distFromReported < exactDist {
exactCS = cs
exactDist = distFromReported
}

// Wrong-quadrant candidate: within the wider radius from ac itself, at
// a bearing offset from the reported one.
distFromAc := math.NMDistance2LL(ac.Position(), other.Position())
if distFromAc > wqMaxRangeNM {
continue
}
bearingTrue := math.Heading2LL(ac.Position(), other.Position(), nmPerLong)
angleOff := math.HeadingDifference(bearingTrue, reportedHeadingTrue)
if angleOff < wqMinAngle || angleOff > wqMaxAngle {
continue
}
if distFromAc < wqDist {
wqCS = cs
wqDist = distFromAc
}
}

if exactCS != "" {
return advisoryMatchExact, exactCS, reportedOclock
}
if wqCS != "" {
other := s.Aircraft[wqCS]
bearingTrue := math.Heading2LL(ac.Position(), other.Position(), nmPerLong)
bearingMag := math.TrueToMagnetic(bearingTrue, magVar)
rel := math.NormalizeHeading(float32(bearingMag) - float32(ac.Heading()))
return advisoryMatchWrongQuadrant, wqCS, math.HeadingAsHour(rel)
}
return advisoryMatchNone, "", 0
}

// nearestMETAR returns the METAR and airport elevation for the reporting
// station closest to pos. Returns a zero METAR if no stations are available.
func (s *Sim) nearestMETAR(pos math.Point2LL) (wx.METAR, float32) {
Expand Down
5 changes: 5 additions & 0 deletions sim/radio.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const (
PendingTransmissionRequestVisual // Spontaneous "field in sight, requesting visual"
PendingTransmissionRequestVectors // Pilot requesting vectors (overshot localizer)
PendingTransmissionRequestAltitude // Pilot requesting altitude after being vectored off STAR
PendingTransmissionTrafficWhere // Pilot proactively asks "where's that traffic" after 30-60s of looking
)

// FutureFrequencyChange represents a pilot switching to a new frequency.
Expand Down Expand Up @@ -423,6 +424,10 @@ func (s *Sim) GenerateContactTransmission(pc *PendingContact) (spokenText, writt
case PendingTransmissionTrafficInSight:
rt = av.MakeContactTransmission("[we've got the traffic|we have the traffic in sight|traffic in sight now]")

case PendingTransmissionTrafficWhere:
rt = av.MakeContactTransmission("[where's that traffic|request update on that traffic|we still don't have the traffic]")
rt.Type = av.RadioTransmissionUnexpected

case PendingTransmissionFieldInSight:
rt = av.MakeContactTransmission("[we have the field in sight now|field in sight|we have the airport in sight now]")

Expand Down
11 changes: 11 additions & 0 deletions sim/sim.go
Original file line number Diff line number Diff line change
Expand Up @@ -1166,11 +1166,22 @@ func (s *Sim) updateState() {
if math.NMDistance2LL(ac.Position(), s.State.Center) > maxDist {
s.lg.Debug("culled far-away aircraft", slog.String("adsb_callsign", string(callsign)))
s.deleteAircraft(ac)
continue
}

// Enqueue a spontaneous "field in sight" transmission if the pilot
// wants to report and the field is currently visible.
s.checkSpontaneousVisualRequest(ac)

// Pilot proactively asks "where's that traffic" 30-60s after a
// "looking" reply if they still haven't sighted it.
if utc := ac.UnseenTrafficCall; utc != nil && !utc.WhereAskFireTime.IsZero() &&
s.State.SimTime.After(utc.WhereAskFireTime) {
utc.WhereAskFireTime = Time{}
if ac.ControllerFrequency != "" {
s.enqueuePilotTransmission(ac.ADSBCallsign, TCP(ac.ControllerFrequency), PendingTransmissionTrafficWhere)
}
}
}

s.possiblyRequestFlightFollowing()
Expand Down
4 changes: 2 additions & 2 deletions sim/visual_approach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1348,8 +1348,8 @@ func TestTrafficAdvisoryClearsOfferedStateButKeepsSightingHistory(t *testing.T)
if sighting.OfferedToMaintainSeparation {
t.Fatal("new traffic advisory should clear stale offered-to-maintain state")
}
if vs.AC.UnseenTrafficCall != nil {
t.Fatal("no-traffic advisory response should clear the unresolved unseen traffic call")
if utc := vs.AC.UnseenTrafficCall; utc != nil && utc.Callsign == "DAL456" {
t.Fatal("no-traffic advisory response should clear the prior unresolved unseen traffic call (for DAL456)")
}
if len(vs.Sim.FutureTrafficInSights) != 0 {
t.Fatal("new traffic advisory should cancel stale delayed traffic-in-sight events")
Expand Down