diff --git a/aviation/intent.go b/aviation/intent.go index 99aae47cb..2e13162ec 100644 --- a/aviation/intent.go +++ b/aviation/intent.go @@ -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 diff --git a/nav/approach.go b/nav/approach.go index 36e051a63..e7409871e 100644 --- a/nav/approach.go +++ b/nav/approach.go @@ -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, @@ -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 diff --git a/nav/commands.go b/nav/commands.go index e1c196a8b..3dd3fc420 100644 --- a/nav/commands.go +++ b/nav/commands.go @@ -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 diff --git a/sim/command_parser.go b/sim/command_parser.go index e8152bbe1..63e4c863c 100644 --- a/sim/command_parser.go +++ b/sim/command_parser.go @@ -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 { @@ -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]) @@ -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) diff --git a/sim/commands.go b/sim/commands.go index f7e69a6ea..125edfe07 100644 --- a/sim/commands.go +++ b/sim/commands.go @@ -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)". diff --git a/sim/visual_approach_test.go b/sim/visual_approach_test.go index 5a111b311..58dd5c4b3 100644 --- a/sim/visual_approach_test.go +++ b/sim/visual_approach_test.go @@ -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 @@ -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) + } + }) + } +} diff --git a/stt/handlers.go b/stt/handlers.go index 72e62371f..09233cc9a 100644 --- a/stt/handlers.go +++ b/stt/handlers.go @@ -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) },