From bdf03da8f9c88ac064639ee2d20891661faa3458 Mon Sep 17 00:00:00 2001 From: Jordan Williams Date: Mon, 15 Jun 2026 20:37:47 -0400 Subject: [PATCH] Add pilot "request to slow down" for fast arrivals (#884) When a controller holds an arrival fast as it nears the field, the pilot now asks to slow down. A per-tick check compares the assigned speed floor against a standard speed-by-distance profile (250/210/190/180 kt gates) and enqueues a request when the aircraft is too fast for its distance. The request is made once per speed-gate band, re-asking only on crossing into a closer band; a new controller speed assignment re-arms it. Distance uses remaining track miles on a lateral route, falling back to straight-line distance to the approach threshold when vectored toward it. The request is re-validated at dispatch time and dropped if the controller has since slowed the aircraft or cleared the assignment. Includes unit tests for the speed floor, distance estimate, gate profile, the per-tick request logic, and transmission generation. Co-Authored-By: Claude Opus 4.8 (1M context) --- nav/slowdown_test.go | 95 +++++++++++++++ nav/speed.go | 57 +++++++++ sim/aircraft.go | 11 ++ sim/approach.go | 100 +++++++++++++++ sim/radio.go | 32 +++++ sim/sim.go | 4 + sim/slowdown_test.go | 285 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 584 insertions(+) create mode 100644 nav/slowdown_test.go create mode 100644 sim/slowdown_test.go diff --git a/nav/slowdown_test.go b/nav/slowdown_test.go new file mode 100644 index 000000000..c5527e041 --- /dev/null +++ b/nav/slowdown_test.go @@ -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) + } + }) +} diff --git a/nav/speed.go b/nav/speed.go index cc1b81b03..ea7dcf5b0 100644 --- a/nav/speed.go +++ b/nav/speed.go @@ -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) { diff --git a/sim/aircraft.go b/sim/aircraft.go index 211b8df44..685094acf 100644 --- a/sim/aircraft.go +++ b/sim/aircraft.go @@ -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 } diff --git a/sim/approach.go b/sim/approach.go index 284e666e5..b6b79e27f 100644 --- a/sim/approach.go +++ b/sim/approach.go @@ -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 +} diff --git a/sim/radio.go b/sim/radio.go index adb6de705..d6deb0ff9 100644 --- a/sim/radio.go +++ b/sim/radio.go @@ -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. @@ -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 { @@ -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 "", "" diff --git a/sim/sim.go b/sim/sim.go index 4c168da5a..52c64d912 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -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() diff --git a/sim/slowdown_test.go b/sim/slowdown_test.go new file mode 100644 index 000000000..014607555 --- /dev/null +++ b/sim/slowdown_test.go @@ -0,0 +1,285 @@ +// sim/slowdown_test.go +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package sim + +import ( + "io" + "log/slog" + "strings" + "testing" + + av "github.com/mmp/vice/aviation" + "github.com/mmp/vice/log" + "github.com/mmp/vice/math" + "github.com/mmp/vice/nav" + vrand "github.com/mmp/vice/rand" +) + +func TestArrivalSpeedGate(t *testing.T) { + cases := []struct { + dist float32 + wantGate float32 + wantOK bool + }{ + {25, 0, false}, + {20, 250, true}, + {19, 250, true}, + {18, 250, true}, + {17, 210, true}, + {14, 210, true}, + {13, 190, true}, + {8, 190, true}, + {7, 180, true}, + {2, 180, true}, + } + for _, c := range cases { + gate, ok := arrivalSpeedGate(c.dist) + if ok != c.wantOK || (ok && gate != c.wantGate) { + t.Errorf("arrivalSpeedGate(%.0f) = %v, %v; want %v, %v", c.dist, gate, ok, c.wantGate, c.wantOK) + } + } +} + +// slowDownTestAircraft builds an associated arrival on a lateral route whose +// single waypoint is distNM ahead, so SlowDownDistanceNM returns ~distNM. +func slowDownTestAircraft(distNM float32, assigned *av.SpeedRestriction) *Aircraft { + const nmPerLong = 60 + ac := &Aircraft{ + ADSBCallsign: "AAL123", + TypeOfFlight: av.FlightTypeArrival, + ControllerFrequency: "BOS_APP", + NASFlightPlan: &NASFlightPlan{}, + } + ac.Nav.FlightState = nav.FlightState{ + Position: math.NM2LL([2]float32{0, 0}, nmPerLong), + NmPerLongitude: nmPerLong, + } + ac.Nav.Waypoints = av.WaypointArray{{Fix: "RWY", Location: math.NM2LL([2]float32{0, distNM}, nmPerLong)}} + ac.Nav.Speed.Assigned = assigned + return ac +} + +func slowDownRequestCount(s *Sim, callsign av.ADSBCallsign) int { + n := 0 + for _, pcs := range s.PendingContacts { + for _, pc := range pcs { + if pc.ADSBCallsign == callsign && pc.Type == PendingTransmissionRequestSlowDown { + n++ + } + } + } + return n +} + +func at(speed float32) *av.SpeedRestriction { + r := av.MakeAtSpeedRestriction(speed) + return &r +} + +func TestCheckSlowDownRequest(t *testing.T) { + t.Run("fast and close asks once per band", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(12, at(250)) // 12nm -> 190kt gate, assigned 250 + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 1 { + t.Fatalf("after first check: %d requests, want 1", got) + } + if ac.SlowDownAskedGate != 190 { + t.Fatalf("SlowDownAskedGate = %.0f, want 190", ac.SlowDownAskedGate) + } + // Same band: no repeat. + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 1 { + t.Fatalf("after second check in same band: %d requests, want 1", got) + } + }) + + t.Run("re-asks on crossing into a closer band once the prior request clears", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(12, at(250)) + s.checkSlowDownRequest(ac) // 190 band + // The controller pops/answers the first request. + s.PendingContacts = nil + // Move inside 8nm -> 180kt gate; the closer band re-asks. + ac.Nav.Waypoints[0].Location = math.NM2LL([2]float32{0, 6}, 60) + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 1 { + t.Fatalf("after crossing into closer band: %d requests, want 1", got) + } + }) + + t.Run("re-arms on a new speed assignment within the same band", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(12, at(250)) // 190 band + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 1 { + t.Fatalf("after first check: %d requests, want 1", got) + } + // Controller acknowledges (the queued request is delivered) and re-assigns + // a still-too-fast speed. Same distance/band, but the changed assignment + // re-arms the request. + s.PendingContacts = nil + ac.Nav.Speed.Assigned = at(240) + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 1 { + t.Fatalf("after new assignment in same band: %d requests, want 1 (re-armed)", got) + } + }) + + t.Run("does not stack a duplicate while a request is still queued", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(14, at(250)) // 210 band + s.checkSlowDownRequest(ac) + // Cross into the 190 band before the controller pops the first request. + ac.Nav.Waypoints[0].Location = math.NM2LL([2]float32{0, 12}, 60) + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 1 { + t.Fatalf("queued duplicate: %d requests, want 1", got) + } + }) + + t.Run("no request when assigned speed satisfies the gate", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(12, at(190)) // exactly the 190 gate + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 0 { + t.Fatalf("%d requests, want 0 (not too fast)", got) + } + }) + + t.Run("no request without a speed assignment", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(12, nil) + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 0 { + t.Fatalf("%d requests, want 0 (no assignment)", got) + } + }) + + t.Run("does not ask while moving away from the field", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(12, at(250)) + // Pretend the previous tick was closer in (10nm), so at 12nm the + // aircraft is now receding — e.g. it overflew or went missed. + ac.SlowDownLastDist = 10 + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 0 { + t.Fatalf("%d requests, want 0 while moving away", got) + } + }) + + t.Run("departures, unassociated, and off-frequency aircraft never ask", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(12, at(250)) + ac.TypeOfFlight = av.FlightTypeDeparture + s.checkSlowDownRequest(ac) + + ac2 := slowDownTestAircraft(12, at(250)) + ac2.NASFlightPlan = nil // unassociated + s.checkSlowDownRequest(ac2) + + ac3 := slowDownTestAircraft(12, at(250)) + ac3.ControllerFrequency = "" // off-frequency (e.g. radar services terminated) + s.checkSlowDownRequest(ac3) + + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 0 { + t.Fatalf("%d requests, want 0", got) + } + if got := slowDownRequestCount(s, ac3.ADSBCallsign); got != 0 { + t.Fatalf("%d requests, want 0 while off-frequency", got) + } + }) +} + +// slowDownSim wraps ac in a minimal Sim sufficient to drive +// GenerateContactTransmission. With no Controllers configured the controller +// lookup is nil, so the function returns the unprefixed base text — exactly +// what we want to assert on. +func slowDownSim(ac *Aircraft, seed uint64) *Sim { + lg := &log.Logger{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + r := vrand.Make() + r.Seed(seed) + return &Sim{ + lg: lg, + Rand: r, + State: &CommonState{}, + Aircraft: map[av.ADSBCallsign]*Aircraft{ac.ADSBCallsign: ac}, + PendingContacts: make(map[TCP][]PendingContact), + eventStream: NewEventStream(lg), + } +} + +func TestGenerateSlowDownTransmission(t *testing.T) { + // dispatch renders the request once with a fixed seed so the random + // phrasing choice is reproducible. + dispatch := func(ac *Aircraft, seed uint64) (string, string) { + s := slowDownSim(ac, seed) + return s.GenerateContactTransmission(&PendingContact{ + ADSBCallsign: ac.ADSBCallsign, + TCP: ac.ControllerFrequency, + Type: PendingTransmissionRequestSlowDown, + }) + } + + t.Run("references the assigned speed when still too fast", func(t *testing.T) { + // 12nm -> 190kt gate, assigned 250: still too fast, so it always asks. + // Only some of the phrasings name the speed, so sweep seeds: every + // transmission must be non-empty, and the speed-naming branch must + // render 250 (never a different number). + sawSpeed := false + for seed := uint64(0); seed < 50; seed++ { + _, written := dispatch(slowDownTestAircraft(12, at(250)), seed) + if written == "" { + t.Fatalf("seed %d: expected a transmission, got none", seed) + } + if strings.Contains(written, "250") { + sawSpeed = true + } else if strings.ContainsAny(written, "0123456789") { + t.Errorf("seed %d: written = %q references a speed other than 250", seed, written) + } + } + if !sawSpeed { + t.Error("no sampled phrasing referenced the assigned speed 250") + } + }) + + t.Run("maintain maximum forward omits a speed", func(t *testing.T) { + ac := slowDownTestAircraft(12, nil) + ac.Nav.Speed.MaintainMaximumForward = true + for seed := uint64(0); seed < 50; seed++ { + spoken, written := dispatch(ac, seed) + if spoken == "" || written == "" { + t.Fatalf("seed %d: expected a transmission, got spoken=%q written=%q", seed, spoken, written) + } + if strings.ContainsAny(written, "0123456789") { + t.Errorf("seed %d: written = %q should not name a speed", seed, written) + } + } + }) + + t.Run("drops the request when the assignment was removed", func(t *testing.T) { + // Enqueued while fast, but the controller has since cleared the speed. + if spoken, written := dispatch(slowDownTestAircraft(12, nil), 0); spoken != "" || written != "" { + t.Errorf("expected no transmission, got spoken=%q written=%q", spoken, written) + } + }) + + t.Run("drops the request when slowed to satisfy the gate", func(t *testing.T) { + // 12nm -> 190kt gate; controller has since assigned exactly 190. + if spoken, written := dispatch(slowDownTestAircraft(12, at(190)), 0); spoken != "" || written != "" { + t.Errorf("expected no transmission, got spoken=%q written=%q", spoken, written) + } + }) + + t.Run("drops the request for a mach assignment", func(t *testing.T) { + mach := &av.SpeedRestriction{ + NavigationRestriction: av.NavigationRestriction{Range: [2]float32{0.74, 0.74}}, + IsMach: true, + } + if spoken, written := dispatch(slowDownTestAircraft(12, mach), 0); spoken != "" || written != "" { + t.Errorf("expected no transmission, got spoken=%q written=%q", spoken, written) + } + }) +}