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
95 changes: 95 additions & 0 deletions nav/slowdown_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// nav/slowdown_test.go
// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3.
// SPDX: GPL-3.0-only

package nav

import (
"testing"

av "github.com/mmp/vice/aviation"
"github.com/mmp/vice/math"
)

func TestAssignedSpeedFloor(t *testing.T) {
mkRange := func(lo, hi float32) *av.SpeedRestriction {
return &av.SpeedRestriction{NavigationRestriction: av.NavigationRestriction{Range: [2]float32{lo, hi}}}
}

cases := []struct {
name string
speed NavSpeed
wantFloor float32
wantOK bool
}{
{"none", NavSpeed{}, 0, false},
{"exact", NavSpeed{Assigned: mkRange(250, 250)}, 250, true},
{"range uses floor", NavSpeed{Assigned: mkRange(210, 250)}, 210, true},
{"mach ignored", NavSpeed{Assigned: &av.SpeedRestriction{
NavigationRestriction: av.NavigationRestriction{Range: [2]float32{0.74, 0.74}}, IsMach: true}}, 0, false},
{"max forward", NavSpeed{MaintainMaximumForward: true}, av.MaxRestrictionSpeed, true},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n := &Nav{Speed: c.speed}
floor, ok := n.AssignedSpeedFloor()
if ok != c.wantOK || (ok && floor != c.wantFloor) {
t.Errorf("AssignedSpeedFloor() = %v, %v; want %v, %v", floor, ok, c.wantFloor, c.wantOK)
}
})
}
}

func TestSlowDownDistanceNM(t *testing.T) {
const nmPerLong = 60
pt := func(x, y float32) math.Point2LL { return math.NM2LL([2]float32{x, y}, nmPerLong) }

t.Run("route sums remaining legs", func(t *testing.T) {
n := &Nav{
FlightState: FlightState{Position: pt(0, 0), NmPerLongitude: nmPerLong},
Waypoints: av.WaypointArray{
{Fix: "WP0", Location: pt(0, 5)},
{Fix: "WP1", Location: pt(0, 15)},
},
}
d, ok := n.SlowDownDistanceNM()
if !ok || math.Abs(d-15) > 0.1 {
t.Errorf("SlowDownDistanceNM() = %.2f, %v; want ~15, true", d, ok)
}
})

// Being vectored: route branch is disabled regardless of waypoints.
vectored := func(acHeading math.MagneticHeading) *Nav {
h := math.MagneticHeading(123) // any non-nil assigned heading
return &Nav{
FlightState: FlightState{Position: pt(0, 0), Heading: acHeading, NmPerLongitude: nmPerLong},
Heading: NavHeading{Assigned: &h},
Approach: NavApproach{Assigned: &av.Approach{Threshold: pt(0, 10)}},
}
}

t.Run("vectored toward threshold", func(t *testing.T) {
d, ok := vectored(0 /* north, toward threshold */).SlowDownDistanceNM()
if !ok || math.Abs(d-10) > 0.1 {
t.Errorf("SlowDownDistanceNM() = %.2f, %v; want ~10, true", d, ok)
}
})

t.Run("vectored away from threshold", func(t *testing.T) {
if d, ok := vectored(180 /* south, away */).SlowDownDistanceNM(); ok {
t.Errorf("SlowDownDistanceNM() = %.2f, %v; want not-ok while heading away", d, ok)
}
})

t.Run("vectored without assigned approach", func(t *testing.T) {
h := math.MagneticHeading(90)
n := &Nav{
FlightState: FlightState{Position: pt(0, 0), NmPerLongitude: nmPerLong},
Heading: NavHeading{Assigned: &h},
}
if d, ok := n.SlowDownDistanceNM(); ok {
t.Errorf("SlowDownDistanceNM() = %.2f, %v; want not-ok with no runway reference", d, ok)
}
})
}
57 changes: 57 additions & 0 deletions nav/speed.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,63 @@ func (nav *Nav) getUpcomingSpeedRestrictionWaypoint() (onSID bool, sr *av.SpeedR
return false, nil, "", false
}

// AssignedSpeedFloor returns the lowest speed (in knots) the aircraft may fly
// given the current controller speed assignment, along with whether such an
// assignment is in force. It is used to decide whether an arrival should ask
// to slow down (issue #884); Mach assignments are ignored since they only
// apply far from the airport.
func (nav *Nav) AssignedSpeedFloor() (float32, bool) {
if nav.Speed.MaintainMaximumForward {
// "Maintain maximum forward speed" exceeds any distance-based gate.
return av.MaxRestrictionSpeed, true
}
if sr := nav.Speed.Assigned; sr != nil && !sr.IsMach {
return sr.Range[0], true
}
return 0, false
}

// SlowDownDistanceNM estimates the distance (in nm) the pilot would use when
// judging whether they are "getting close" to the runway for the purpose of
// requesting a speed reduction (issue #884), along with whether that estimate
// is meaningful.
//
// When flying a lateral route (not being vectored) it returns the remaining
// track miles along the route, which correctly accounts for downwind/base
// geometry. When being vectored it falls back to the straight-line distance to
// the assigned approach's runway threshold, but only when the aircraft is
// actually flying toward it (within 90 degrees); otherwise it returns
// ok=false, so no premature request is made while on a downwind or base leg
// where the aircraft can be close to the threshold but heading away from it.
func (nav *Nav) SlowDownDistanceNM() (float32, bool) {
fs := &nav.FlightState

if nav.Heading.Assigned == nil && len(nav.Waypoints) > 0 {
// Sum the remaining track miles. We do not verify the final waypoint is
// the runway threshold: a not-yet-cleared arrival on a full STAR yields a
// large distance, which arrivalSpeedGate rejects (>20 nm) anyway.
d := math.NMDistance2LLFast(fs.Position, nav.Waypoints[0].Location, fs.NmPerLongitude)
for i := 0; i+1 < len(nav.Waypoints); i++ {
d += math.NMDistance2LLFast(nav.Waypoints[i].Location, nav.Waypoints[i+1].Location,
fs.NmPerLongitude)
}
return d, true
}

// Being vectored: we can only estimate distance if we know which runway
// we're being taken to.
if nav.Approach.Assigned == nil {
return 0, false
}
threshold := nav.Approach.Assigned.Threshold
bearing := math.Heading2LL(fs.Position, threshold, fs.NmPerLongitude)
heading := math.MagneticToTrue(fs.Heading, fs.MagneticVariation)
if math.HeadingDifference(heading, bearing) > 90 {
return 0, false
}
return math.NMDistance2LLFast(fs.Position, threshold, fs.NmPerLongitude), true
}

// distanceToEndOfApproach returns the remaining distance to the last
// waypoint (usually runway threshold) of the currently assigned approach.
func (nav *Nav) DistanceToEndOfApproach() (float32, error) {
Expand Down
11 changes: 11 additions & 0 deletions sim/aircraft.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,17 @@ type Aircraft struct {
// field is in sight. Set to zero after the check (requested or given up) to prevent retries.
VisualApproachRequestDistance float32

// SlowDownAskedGate is the speed gate (kts) at which the pilot last asked
// to slow down (issue #884); 0 means no request has been made. The pilot
// only re-asks on crossing into a closer (lower-speed) gate band.
// SlowDownAskedFloor is the assigned speed floor in force when that ask was
// (re-)armed; a change in the assignment re-arms the request. SlowDownLastDist
// is the previous tick's distance, used to detect when the aircraft is
// receding from the field (overflew/missed) so it stays quiet.
SlowDownAskedGate float32
SlowDownAskedFloor float32
SlowDownLastDist float32

TouchAndGosRemaining int // >0 means pattern aircraft; decremented each lap
}

Expand Down
100 changes: 100 additions & 0 deletions sim/approach.go
Original file line number Diff line number Diff line change
Expand Up @@ -548,3 +548,103 @@ func (s *Sim) checkSpontaneousVisualRequest(ac *Aircraft) {
s.enqueuePilotTransmission(ac.ADSBCallsign, ac.ControllerFrequency, PendingTransmissionFieldInSight)
}
}

// arrivalSpeedGate returns the maximum speed (in knots) an arrival would
// typically want to be doing at the given distance (in nm) from the airport,
// per the standard TRACON speed-by-distance profile (issue #884). ok is false
// beyond 20 nm, where there's no expectation to have slowed.
func arrivalSpeedGate(distNM float32) (float32, bool) {
switch {
case distNM > 20:
return 0, false
case distNM >= 18:
return 250, true
case distNM >= 14:
return 210, true
case distNM >= 8:
return 190, true
default:
return 180, true
}
}

// checkSlowDownRequest is a per-tick check for an arrival that is being held
// fast by the controller as it nears the airport. If the controller-assigned
// speed exceeds what the aircraft would typically fly at its current distance
// (see arrivalSpeedGate / issue #884), the pilot asks to slow down. The
// request is made once per speed-gate band and only repeats when the aircraft
// crosses into a closer (lower-speed) band; a new controller speed assignment
// re-arms it.
func (s *Sim) checkSlowDownRequest(ac *Aircraft) {
if !ac.IsArrival() || !ac.IsAssociated() || ac.ControllerFrequency == "" || s.hasPendingCheckIn(ac.ADSBCallsign) {
return
}

floor, ok := ac.Nav.AssignedSpeedFloor()
if !ok {
// No controller speed assignment in force: nothing to ask about, and
// re-arm so a subsequent assignment is treated fresh.
ac.SlowDownLastDist = 0
ac.SlowDownAskedGate = 0
ac.SlowDownAskedFloor = 0
return
}

// A new (or changed) controller speed assignment re-arms the request, so the
// pilot will speak up again even within the same speed-gate band.
if floor != ac.SlowDownAskedFloor {
ac.SlowDownAskedGate = 0
ac.SlowDownAskedFloor = floor
}

dist, ok := ac.Nav.SlowDownDistanceNM()
if !ok {
ac.SlowDownLastDist = 0
return
}

// Only ask while still inbound. Once the aircraft is moving away from the
// field (overflew the airport, on the missed approach, etc.) stay quiet.
movingAway := ac.SlowDownLastDist != 0 && dist > ac.SlowDownLastDist+0.1
ac.SlowDownLastDist = dist
if movingAway {
return
}

gate, ok := arrivalSpeedGate(dist)
if !ok || floor <= gate {
return
}

// Ask once per band, re-asking only on crossing into a closer (i.e.
// lower-speed) band.
if ac.SlowDownAskedGate != 0 && gate >= ac.SlowDownAskedGate {
return
}
// Don't stack a duplicate if an earlier request is still queued (the
// controller hasn't popped it yet); it re-checks the gate at dispatch.
if s.hasPendingTransmission(ac.ADSBCallsign, PendingTransmissionRequestSlowDown) {
return
}
ac.SlowDownAskedGate = gate
s.enqueuePilotTransmission(ac.ADSBCallsign, ac.ControllerFrequency, PendingTransmissionRequestSlowDown)
}

// stillWantsSlowDown reports whether a queued slow-down request is still worth
// transmitting at dispatch time. The controller may have slowed the aircraft
// between enqueue and dispatch, so it re-checks that a speed assignment is in
// force and, where the distance to the field can be estimated, that the
// assignment still exceeds the speed-by-distance gate (see checkSlowDownRequest
// / issue #884).
func (ac *Aircraft) stillWantsSlowDown() bool {
floor, ok := ac.Nav.AssignedSpeedFloor()
if !ok {
return false
}
if dist, ok := ac.Nav.SlowDownDistanceNM(); ok {
if gate, ok := arrivalSpeedGate(dist); ok && floor <= gate {
return false
}
}
return true
}
32 changes: 32 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
PendingTransmissionRequestSlowDown // Arrival held fast close in, asking to slow down
)

// FutureFrequencyChange represents a pilot switching to a new frequency.
Expand Down Expand Up @@ -92,6 +93,19 @@ func (s *Sim) hasPendingCheckIn(callsign av.ADSBCallsign) bool {
return false
}

// hasPendingTransmission reports whether a transmission of the given type is
// already queued for the aircraft, used to avoid enqueuing duplicate requests.
func (s *Sim) hasPendingTransmission(callsign av.ADSBCallsign, ty PendingTransmissionType) bool {
for _, pcs := range s.PendingContacts {
for _, pc := range pcs {
if pc.ADSBCallsign == callsign && pc.Type == ty {
return true
}
}
}
return false
}

// addPendingContact adds an aircraft to the pending contacts queue for a controller.
func (s *Sim) addPendingContact(pc PendingContact) {
if s.PendingContacts == nil {
Expand Down Expand Up @@ -477,6 +491,24 @@ func (s *Sim) GenerateContactTransmission(pc *PendingContact) (spokenText, writt
rt = av.MakeContactTransmission("[what altitude should we maintain|what altitude do you want us at]")
rt.Type = av.RadioTransmissionUnexpected

case PendingTransmissionRequestSlowDown:
// The controller may have slowed us between enqueue and dispatch; if the
// request is no longer warranted (e.g. now satisfies the speed gate),
// drop it.
if !ac.stillWantsSlowDown() {
return "", ""
}
// All phrasings share this tail; the speed-naming variants are only
// added when there's a specific assigned IAS to reference.
const tail = "are you still going to need the speed|can we start to slow|we need to start slowing here"
if sr := ac.Nav.Speed.Assigned; sr != nil && !sr.IsMach {
rt = av.MakeContactTransmission("[do you still need {spd}|how much longer do you need {spd}|"+tail+"]",
int(sr.Range[0]))
} else {
rt = av.MakeContactTransmission("[" + tail + "]")
}
rt.Type = av.RadioTransmissionUnexpected

case PendingTransmissionEmergency:
if pc.PrebuiltTransmission == nil {
return "", ""
Expand Down
4 changes: 4 additions & 0 deletions sim/sim.go
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,10 @@ func (s *Sim) updateState() {
// Enqueue a spontaneous "field in sight" transmission if the pilot
// wants to report and the field is currently visible.
s.checkSpontaneousVisualRequest(ac)

// Enqueue a "request to slow down" transmission if the arrival is
// being held fast by the controller as it nears the airport.
s.checkSlowDownRequest(ac)
}

s.possiblyRequestFlightFollowing()
Expand Down
Loading
Loading