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
32 changes: 32 additions & 0 deletions aviation/intent.go
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,38 @@ func (c ContactTowerIntent) Render(rt *RadioTransmission, r *rand.Rand) {
}
}

// DirectNumbersIntent is the readback for "direct the numbers".
type DirectNumbersIntent struct {
Turn TurnDirection
}

func (d DirectNumbersIntent) Render(rt *RadioTransmission, r *rand.Rand) {
switch d.Turn {
case TurnLeft:
rt.Add("left [turn|] direct the numbers")
case TurnRight:
rt.Add("right [turn|] direct the numbers")
default:
rt.Add("[direct the numbers|straight to the numbers]")
}
}

// DirectAirportIntent is the readback for "proceed direct the field".
type DirectAirportIntent struct {
Turn TurnDirection
}

func (d DirectAirportIntent) Render(rt *RadioTransmission, r *rand.Rand) {
switch d.Turn {
case TurnLeft:
rt.Add("left [turn|] direct the [field|airport]")
case TurnRight:
rt.Add("right [turn|] direct the [field|airport]")
default:
rt.Add("direct the [field|airport]")
}
}

// ATISIntent represents the pilot's acknowledgment of the ATIS letter.
type ATISIntent struct {
Letter string
Expand Down
12 changes: 11 additions & 1 deletion nav/approach.go
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,7 @@ func (nav *Nav) ClearedVisualApproach(follow *FollowTraffic, lahsoRunway string)
wps = nav.visualApproachRouteFromReferences(runway, joinPos, nav.Approach.VisualReferences)
}
if wps == nil {
return av.MakeUnableIntent("unable, we don't know runway " + runway)
return av.MakeUnableIntent("unable, we're not in position for the visual to runway " + runway)
}

// The synthesized route is built from raw reference / leader waypoints,
Expand Down Expand Up @@ -1156,6 +1156,16 @@ func (nav *Nav) selectVisualApproachRoute(followTraffic *math.Point2LL, referenc
}
bearingToProj := math.Heading2LL(pos, proj.location, nmPerLong)
if math.HeadingDifference(bearingToProj, tHdg) > 90 {
// The aircraft is on a downwind: pointed away from the field with
// the abeam projection behind it. Rather than refusing, join the
// route at a point downwind of the aircraft -- it continues ahead,
// turns in to that fix, and flies the route inbound from there.
joinDist := max(proj.distanceToThreshold+2, 4)
if jp, ok := visualRoutePointAtDistance(proj.route, joinDist, nmPerLong); ok {
jp.lateralDistance = proj.lateralDistance
jp.finalPoint = jp.distanceToThreshold <= 3.25
return &jp
}
return nil
}
return &proj
Expand Down
27 changes: 27 additions & 0 deletions nav/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,33 @@ func (nav *Nav) DistanceAlongRoute(fix string) (float32, error) {
return distance, nil
}

// DirectRunwayNumbers has the pilot proceed visually direct to the
// assigned approach runway's threshold ("direct the numbers").
func (nav *Nav) DirectRunwayNumbers(turn av.TurnDirection, simTime Time, delayReduction time.Duration) av.CommandIntent {
ap := nav.Approach.Assigned
if ap == nil {
return av.MakeUnableIntent("unable. you haven't given us a runway")
}
wps := []av.Waypoint{
{Fix: "_NUMBERS", Location: ap.Threshold},
nav.FlightState.ArrivalAirport,
}
nav.EnqueueDirectFix(wps, turn, simTime, delayReduction)
nav.Approach.NoPT = false
nav.Approach.InterceptState = NotIntercepting
return av.DirectNumbersIntent{Turn: turn}
}

// DirectAirport has the pilot proceed direct to the arrival airport
// ("proceed direct the field").
func (nav *Nav) DirectAirport(turn av.TurnDirection, simTime Time, delayReduction time.Duration) av.CommandIntent {
wps := []av.Waypoint{nav.FlightState.ArrivalAirport}
nav.EnqueueDirectFix(wps, turn, simTime, delayReduction)
nav.Approach.NoPT = false
nav.Approach.InterceptState = NotIntercepting
return av.DirectAirportIntent{Turn: turn}
}

func (nav *Nav) ResumeOwnNavigation() av.CommandIntent {
if nav.Heading.Assigned == nil {
// This is a weird response but keeping the original behavior
Expand Down
19 changes: 17 additions & 2 deletions sim/command_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,13 @@ func (s *Sim) runOneControlCommand(tcw TCW, callsign av.ADSBCallsign, command st
}

case 'D':
if command == "DTN" {
// Direct the (runway) numbers.
return s.DirectRunwayNumbers(tcw, callsign, av.TurnClosest, delayReduction)
} else if command == "DTF" {
// Direct the field/airport.
return s.DirectAirport(tcw, callsign, av.TurnClosest, delayReduction)
}
if command == "DVS" {
return s.DescendViaSTAR(tcw, callsign)
} else if components := strings.Split(command, "/"); len(components) > 1 && len(components[1]) > 1 {
Expand Down Expand Up @@ -736,7 +743,11 @@ func (s *Sim) runOneControlCommand(tcw TCW, callsign av.ADSBCallsign, command st
}

case 'L':
if len(command) >= 5 && command[1] == 'D' {
if command == "LDTN" {
return s.DirectRunwayNumbers(tcw, callsign, av.TurnLeft, delayReduction)
} else if command == "LDTF" {
return s.DirectAirport(tcw, callsign, av.TurnLeft, delayReduction)
} else if len(command) >= 5 && command[1] == 'D' {
return s.DirectFix(tcw, callsign, command[2:], av.TurnLeft, delayReduction)
} else if l := len(command); l > 2 && command[l-1] == 'D' {
deg, err := strconv.Atoi(command[1 : l-1])
Expand Down Expand Up @@ -779,7 +790,11 @@ func (s *Sim) runOneControlCommand(tcw TCW, callsign av.ADSBCallsign, command st
return s.AssignMach(tcw, callsign, float32(mach), false)

case 'R':
if command == "RON" {
if command == "RDTN" {
return s.DirectRunwayNumbers(tcw, callsign, av.TurnRight, delayReduction)
} else if command == "RDTF" {
return s.DirectAirport(tcw, callsign, av.TurnRight, delayReduction)
} else if command == "RON" {
return s.ResumeOwnNavigation(tcw, callsign)
} else if command == "RST" {
return s.RadarServicesTerminated(tcw, callsign)
Expand Down
22 changes: 22 additions & 0 deletions sim/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,28 @@ func (s *Sim) ContactTower(tcw TCW, callsign av.ADSBCallsign, freq av.Frequency)
})
}

// DirectRunwayNumbers handles "turn/proceed direct the numbers".
func (s *Sim) DirectRunwayNumbers(tcw TCW, callsign av.ADSBCallsign, turn av.TurnDirection, delayReduction time.Duration) (av.CommandIntent, error) {
s.mu.Lock(s.lg)
defer s.mu.Unlock(s.lg)

return s.dispatchControlledAircraftCommand(tcw, callsign,
func(tcw TCW, ac *Aircraft) av.CommandIntent {
return ac.Nav.DirectRunwayNumbers(turn, s.State.SimTime.NavTime(), delayReduction)
})
}

// DirectAirport handles "proceed direct the field/airport".
func (s *Sim) DirectAirport(tcw TCW, callsign av.ADSBCallsign, turn av.TurnDirection, delayReduction time.Duration) (av.CommandIntent, error) {
s.mu.Lock(s.lg)
defer s.mu.Unlock(s.lg)

return s.dispatchControlledAircraftCommand(tcw, callsign,
func(tcw TCW, ac *Aircraft) av.CommandIntent {
return ac.Nav.DirectAirport(turn, s.State.SimTime.NavTime(), delayReduction)
})
}

// ATISCommand handles the controller telling a pilot the current ATIS letter.
// If the aircraft already reported the correct ATIS, no readback is needed.
// Otherwise the pilot responds with "we'll pick up (letter)".
Expand Down
97 changes: 97 additions & 0 deletions sim/visual_approach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,12 @@ func TestVisualApproachWaypoints(t *testing.T) {
assigned: ptr(math.MagneticHeading(315)),
wantFirstFix: "_36_3NM_FINAL",
},
{
name: "Downwind, abeam projection behind aircraft — join downwind of it",
pos: math.Point2LL{2.0 / nmPerLong, -6.0 / 60},
heading: 170,
wantFirstFix: "_36_PROJECTION",
},
{
name: "Behind threshold — go around",
pos: math.Point2LL{0, 1.0 / 60}, // 1nm north of threshold
Expand Down Expand Up @@ -2582,3 +2588,94 @@ func TestTrafficInSightInquiryRejectsTooFar(t *testing.T) {
t.Errorf("expected TrafficResponseWhereWasIt for traffic too far, got %v", ti.Response)
}
}

// TestDownwindVisualJoinFlies: clearing an aircraft for the visual from
// the downwind must produce a route it can actually fly to the runway --
// not a join it overshoots into a permanent extended downwind.
func TestDownwindVisualJoinFlies(t *testing.T) {
if len(av.DB.AircraftPerformance) == 0 {
av.InitDB()
}
rwy := av.Runway{Id: "36", Heading: 360, Threshold: math.Point2LL{0, 0},
Elevation: 100, ThresholdCrossingHeight: 50}
setupTestRunway(t, "KTEST", rwy)
nmPerLong := float32(60)

reference := &av.Approach{
Type: av.ILSApproach,
Runway: "36",
Threshold: rwy.Threshold,
Waypoints: []av.WaypointArray{{
{Fix: "FAF36", Location: math.Point2LL{0, -25.0 / 60}},
{Fix: "_36_THRESHOLD", Location: rwy.Threshold},
}},
}

cases := []struct {
name string
pos math.Point2LL
heading math.MagneticHeading
ias float32
}{
{"typical downwind", math.Point2LL{2.0 / nmPerLong, -6.0 / 60}, 175, 210},
{"fast and tight", math.Point2LL{1.0 / nmPerLong, -5.0 / 60}, 180, 250},
{"deep downwind", math.Point2LL{3.0 / nmPerLong, -14.0 / 60}, 170, 230},
{"quartering away", math.Point2LL{2.5 / nmPerLong, -7.0 / 60}, 150, 210},
{"abeam the numbers", math.Point2LL{1.5 / nmPerLong, -1.0 / 60}, 180, 190},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
n := nav.Nav{
FlightState: nav.FlightState{
Position: tc.pos,
Heading: tc.heading,
Altitude: 3000,
IAS: tc.ias,
GS: tc.ias,
NmPerLongitude: nmPerLong,
ArrivalAirport: av.Waypoint{Fix: "KTEST"},
},
Perf: av.DB.AircraftPerformance["B738"],
Approach: nav.NavApproach{
AssignedId: "_VIS36",
Assigned: &av.Approach{
Type: av.VisualApproach,
Runway: "36",
FullName: "Visual Approach Runway 36",
},
VisualReferences: []*av.Approach{reference},
},
}

intent := n.ClearedVisualApproach(nil, "")
if _, unable := intent.(av.UnableIntent); unable {
t.Fatalf("clearance refused: %v", intent)
}

fp := av.FlightPlan{ArrivalAirport: "KTEST"}
metar := wx.METAR{Raw: "KTEST 10SM SKC"}
simTime := NewSimTime(time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC))

minDist := float32(1e9)
maxDist := float32(0)
for i := 0; i < 900; i++ { // 15 minutes
simTime = simTime.Add(time.Second)
n.UpdateWithWeather("TEST", wx.Sample{}, &metar, &fp, simTime.NavTime(), nil)
d := math.NMDistance2LL(n.FlightState.Position, rwy.Threshold)
minDist = min(minDist, d)
maxDist = max(maxDist, d)
if d < 0.3 {
break
}
}

if minDist > 1.0 {
t.Errorf("aircraft never reached the runway: min distance %.1fnm, max %.1fnm, final pos %v, waypoints %v",
minDist, maxDist, n.FlightState.Position, wpNames(n.Waypoints))
}
if maxDist > 18 {
t.Errorf("aircraft wandered %.1fnm from the field (permanent downwind?)", maxDist)
}
})
}
}
23 changes: 23 additions & 0 deletions stt/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,29 @@ func registerAllCommands() {
)

// === NAVIGATION COMMANDS ===
// Direct the numbers / the field (must outrank the generic {fix}
// patterns so "the numbers" isn't fed to the fix matcher).
registerSTTCommand(
"turn|proceed left direct [to] the numbers",
func() string { return "LDTN" },
WithName("left_direct_numbers"), WithPriority(14),
)
registerSTTCommand(
"turn|proceed right direct [to] the numbers",
func() string { return "RDTN" },
WithName("right_direct_numbers"), WithPriority(14),
)
registerSTTCommand(
"turn|proceed [direct] direct [to] the numbers",
func() string { return "DTN" },
WithName("direct_numbers"), WithPriority(13),
)
registerSTTCommand(
"proceed|turn [direct] direct [to] the field|airport",
func() string { return "DTF" },
WithName("direct_field"), WithPriority(13),
)

registerSTTCommand(
"[proceed] left [turn] direct [to] [at] {fix}",
func(fix string) string { return fmt.Sprintf("LD%s", fix) },
Expand Down
Loading