diff --git a/aviation/intent.go b/aviation/intent.go index 89b7bba59..57b408d62 100644 --- a/aviation/intent.go +++ b/aviation/intent.go @@ -882,6 +882,46 @@ func (a ATISIntent) Render(rt *RadioTransmission, r *rand.Rand) { rt.Add("[we'll pick up {ch}|we'll get {ch}]", a.Letter) } +// ConditionalKind identifies which altitude event fires a deferred +// controller command (LV = leaving, RC = reaching). Declared in aviation +// because ConditionalCommandIntent needs it; nav aliases this type. +type ConditionalKind uint8 + +const ( + ConditionalLeaving ConditionalKind = iota + ConditionalReaching +) + +// ConditionalActionRender is the readback-only subset of +// nav.ConditionalAction. Declared here to avoid an import cycle (nav +// imports aviation, not the other way around); nav's ConditionalAction +// interface embeds Render with a compatible signature, so concrete nav +// actions satisfy this interface. +type ConditionalActionRender interface { + Render(rt *RadioTransmission, r *rand.Rand) +} + +// ConditionalCommandIntent is the readback for a "leaving/reaching {alt}, +// do X" command. It composes with the inner action's own Render so +// phraseology stays consistent with non-conditional variants. +type ConditionalCommandIntent struct { + Kind ConditionalKind + Altitude float32 + Action ConditionalActionRender +} + +func (c ConditionalCommandIntent) Render(rt *RadioTransmission, r *rand.Rand) { + switch c.Kind { + case ConditionalLeaving: + rt.Add("[leaving|passing] {alt}, ", c.Altitude) + case ConditionalReaching: + rt.Add("[reaching|level at|on reaching] {alt}, ", c.Altitude) + } + if c.Action != nil { + c.Action.Render(rt, r) + } +} + /////////////////////////////////////////////////////////////////////////// // Traffic Advisory Intent diff --git a/aviation/intent_test.go b/aviation/intent_test.go index 310769c1d..7acf98f64 100644 --- a/aviation/intent_test.go +++ b/aviation/intent_test.go @@ -168,6 +168,44 @@ func TestContactTowerReadback(t *testing.T) { } } +type stubConditionalAction struct{ text string } + +func (s stubConditionalAction) Render(rt *RadioTransmission, r *rand.Rand) { + rt.Add(s.text) +} + +func TestConditionalCommandIntentRender(t *testing.T) { + t.Run("leaving", func(t *testing.T) { + intent := ConditionalCommandIntent{ + Kind: ConditionalLeaving, + Altitude: 3000, + Action: stubConditionalAction{text: "fly heading 010"}, + } + for seed := uint64(1); seed <= 20; seed++ { + readback := renderIntentForTest(intent, seed) + assertContainsAny(t, readback, "leaving", "passing") + if !strings.Contains(readback, "fly heading 010") { + t.Fatalf("leaving readback missing action text: %q", readback) + } + } + }) + + t.Run("reaching", func(t *testing.T) { + intent := ConditionalCommandIntent{ + Kind: ConditionalReaching, + Altitude: 10000, + Action: stubConditionalAction{text: "fly heading 010"}, + } + for seed := uint64(1); seed <= 20; seed++ { + readback := renderIntentForTest(intent, seed) + assertContainsAny(t, readback, "reaching", "level at", "on reaching") + if !strings.Contains(readback, "fly heading 010") { + t.Fatalf("reaching readback missing action text: %q", readback) + } + } + }) +} + func TestCompoundSpeedReadbackIncludesQualifiers(t *testing.T) { above := MakeAtOrAboveSpeedRestriction(250) below := MakeAtOrBelowSpeedRestriction(210) diff --git a/nav/conditional.go b/nav/conditional.go new file mode 100644 index 000000000..6e9e678c2 --- /dev/null +++ b/nav/conditional.go @@ -0,0 +1,178 @@ +// nav/conditional.go +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package nav + +import ( + av "github.com/mmp/vice/aviation" + vmath "github.com/mmp/vice/math" + "github.com/mmp/vice/rand" +) + +// ConditionalKind is an alias for av.ConditionalKind so that nav and +// aviation share the same enum (aviation holds the canonical definition +// because ConditionalCommandIntent lives there and nav cannot be imported +// from aviation). +// +// ConditionalLeaving fires once the aircraft's altitude has passed the +// trigger by more than a small tolerance in the direction of current +// vertical motion. ConditionalReaching fires on first contact within +// 100 ft of the trigger altitude, regardless of vertical rate. +type ConditionalKind = av.ConditionalKind + +const ( + ConditionalLeaving = av.ConditionalLeaving + ConditionalReaching = av.ConditionalReaching +) + +// ConditionalAction is the deferred action to execute when a LV/RC trigger +// fires. Concrete types cover the closed set of supported inner commands +// (heading, direct-fix, speed, mach). +type ConditionalAction interface { + // Execute mutates nav to carry out the deferred action. Called with the + // PendingConditionalCommand slot already cleared, so re-entry is safe. + // temp is the outside air temperature at the aircraft's current altitude, + // required by mach-speed conversions; other actions ignore it. + Execute(nav *Nav, simTime Time, temp av.Temperature) + + // Render emits the action-specific readback fragment (e.g., "fly heading + // 010") used inside ConditionalCommandIntent. + Render(rt *av.RadioTransmission, r *rand.Rand) +} + +// PendingConditionalCommand is the single slot on Nav that stores a +// deferred LV/RC action. A new LV/RC command supersedes any prior slot; +// successful trigger firing clears it. +type PendingConditionalCommand struct { + Kind ConditionalKind + Altitude float32 // feet MSL + Action ConditionalAction +} + +// ConditionalHeading is a deferred heading assignment. Exactly one of +// Heading or ByDegrees is nonzero: +// - Heading != 0 → fly (or turn to) the absolute heading. +// - ByDegrees != 0 → turn N degrees from present heading in the given +// direction (Turn must be TurnLeft or TurnRight). +type ConditionalHeading struct { + Heading int // 1..360, 0 if unused + Turn av.TurnDirection // TurnClosest, TurnLeft, TurnRight + ByDegrees int // nonzero for LnnD / RnnD +} + +func (c ConditionalHeading) Execute(nav *Nav, simTime Time, temp av.Temperature) { + if c.ByDegrees != 0 { + switch c.Turn { + case av.TurnLeft: + nav.assignHeading( + vmath.OffsetHeading(nav.FlightState.Heading, float32(-c.ByDegrees)), + av.TurnLeft, simTime, 0) + case av.TurnRight: + nav.assignHeading( + vmath.OffsetHeading(nav.FlightState.Heading, float32(c.ByDegrees)), + av.TurnRight, simTime, 0) + } + return + } + nav.assignHeading(vmath.MagneticHeading(c.Heading), c.Turn, simTime, 0) +} + +func (c ConditionalHeading) Render(rt *av.RadioTransmission, r *rand.Rand) { + if c.ByDegrees != 0 { + switch c.Turn { + case av.TurnLeft: + rt.Add("[left|turn left] {num} degrees", c.ByDegrees) + case av.TurnRight: + rt.Add("[right|turn right] {num} degrees", c.ByDegrees) + } + return + } + switch c.Turn { + case av.TurnLeft: + rt.Add("[left heading|turn left heading] {hdg}", c.Heading) + case av.TurnRight: + rt.Add("[right heading|turn right heading] {hdg}", c.Heading) + default: + rt.Add("[fly heading|heading] {hdg}", c.Heading) + } +} + +// ConditionalDirectFix is a deferred direct-to-fix instruction. +type ConditionalDirectFix struct { + Fix string + Turn av.TurnDirection // TurnClosest, TurnLeft, TurnRight +} + +func (c ConditionalDirectFix) Execute(nav *Nav, simTime Time, temp av.Temperature) { + // Silent fire path — discard the intent because conditional actions + // don't produce a readback when they fire. + _ = nav.DirectFix(c.Fix, c.Turn, simTime, 0) +} + +func (c ConditionalDirectFix) Render(rt *av.RadioTransmission, r *rand.Rand) { + switch c.Turn { + case av.TurnLeft: + rt.Add("[left direct|turn left direct] {fix}", c.Fix) + case av.TurnRight: + rt.Add("[right direct|turn right direct] {fix}", c.Fix) + default: + rt.Add("[direct|proceed direct] {fix}", c.Fix) + } +} + +// ConditionalSpeed is a deferred speed assignment. +type ConditionalSpeed struct { + Restriction av.SpeedRestriction +} + +func (c ConditionalSpeed) Execute(nav *Nav, simTime Time, temp av.Temperature) { + sr := c.Restriction + _ = nav.AssignSpeed(&sr, false) +} + +func (c ConditionalSpeed) Render(rt *av.RadioTransmission, r *rand.Rand) { + if spd, ok := c.Restriction.ExactValue(); ok { + rt.Add("[reduce speed to|maintain|slowing to] {spd}", int(spd)) + } +} + +// ConditionalMach is a deferred mach-speed assignment. +type ConditionalMach struct { + Mach float32 +} + +func (c ConditionalMach) Execute(nav *Nav, simTime Time, temp av.Temperature) { + _ = nav.AssignMach(c.Mach, false, temp) +} + +func (c ConditionalMach) Render(rt *av.RadioTransmission, r *rand.Rand) { + rt.Add("[mach|maintain mach] {mach}", c.Mach) +} + +// ConditionalTriggered reports whether the pending conditional command +// should fire given the aircraft's current vertical state. +// +// ConditionalLeaving: fires when altitude is >50 ft past trigger in the +// direction of current vertical motion. +// ConditionalReaching: fires when altitude is within 100 ft of trigger. +func ConditionalTriggered(nav *Nav, pc *PendingConditionalCommand) bool { + alt := nav.FlightState.Altitude + diff := alt - pc.Altitude + switch pc.Kind { + case ConditionalLeaving: + const leavingTol = 50.0 + if vmath.Abs(diff) <= leavingTol { + return false + } + rate := nav.FlightState.AltitudeRate + // Same-sign check: diff>0 (above trigger) requires rate>0 (climbing), + // diff<0 (below) requires rate<0 (descending). Zero rate with altitude + // drift outside tolerance (unusual but possible) is not a trigger. + return (diff > 0 && rate > 0) || (diff < 0 && rate < 0) + case ConditionalReaching: + const reachingTol = 100.0 + return vmath.Abs(diff) <= reachingTol + } + return false +} diff --git a/nav/conditional_test.go b/nav/conditional_test.go new file mode 100644 index 000000000..ab236ee7a --- /dev/null +++ b/nav/conditional_test.go @@ -0,0 +1,213 @@ +package nav + +import ( + "strings" + "testing" + + av "github.com/mmp/vice/aviation" + vmath "github.com/mmp/vice/math" + vrand "github.com/mmp/vice/rand" +) + +func TestNavHasPendingConditionalCommandField(t *testing.T) { + var n Nav + if n.PendingConditionalCommand != nil { + t.Fatalf("PendingConditionalCommand should default to nil, got %+v", n.PendingConditionalCommand) + } + n.PendingConditionalCommand = &PendingConditionalCommand{ + Kind: ConditionalLeaving, + Altitude: 3000, + } + if n.PendingConditionalCommand.Kind != ConditionalLeaving { + t.Fatalf("expected ConditionalLeaving, got %d", n.PendingConditionalCommand.Kind) + } + if n.PendingConditionalCommand.Altitude != 3000 { + t.Fatalf("expected 3000, got %v", n.PendingConditionalCommand.Altitude) + } +} + +func TestConditionalHeadingExecuteClosest(t *testing.T) { + n := makeTestNav(t, 180) + action := ConditionalHeading{Heading: 10, Turn: av.TurnClosest} + action.Execute(&n, Time{}, av.Temperature{}) + if assigned, ok := n.AssignedHeading(); !ok || assigned != 10 { + t.Fatalf("expected assigned heading 10, got ok=%v heading=%v", ok, assigned) + } +} + +func TestConditionalHeadingExecuteByDegreesLeft(t *testing.T) { + n := makeTestNav(t, 180) + action := ConditionalHeading{ByDegrees: 30, Turn: av.TurnLeft} + action.Execute(&n, Time{}, av.Temperature{}) + // TurnLeft 30 from 180 -> 150 + if assigned, ok := n.AssignedHeading(); !ok || assigned != 150 { + t.Fatalf("expected assigned heading 150, got ok=%v heading=%v", ok, assigned) + } +} + +func TestConditionalHeadingExecuteByDegreesRight(t *testing.T) { + n := makeTestNav(t, 180) + action := ConditionalHeading{ByDegrees: 30, Turn: av.TurnRight} + action.Execute(&n, Time{}, av.Temperature{}) + // TurnRight 30 from 180 -> 210 + if assigned, ok := n.AssignedHeading(); !ok || assigned != 210 { + t.Fatalf("expected assigned heading 210, got ok=%v heading=%v", ok, assigned) + } +} + +func TestConditionalHeadingRender(t *testing.T) { + cases := []struct { + action ConditionalHeading + want string // substring in written form + }{ + {ConditionalHeading{Heading: 10, Turn: av.TurnClosest}, "010"}, + {ConditionalHeading{Heading: 100, Turn: av.TurnRight}, "right"}, + {ConditionalHeading{Heading: 100, Turn: av.TurnLeft}, "left"}, + {ConditionalHeading{ByDegrees: 20, Turn: av.TurnLeft}, "left 20"}, + {ConditionalHeading{ByDegrees: 20, Turn: av.TurnRight}, "right 20"}, + } + r := vrand.Make() + for _, tc := range cases { + rt := &av.RadioTransmission{} + tc.action.Render(rt, r) + written := rt.Written(r) + if !strings.Contains(strings.ToLower(written), strings.ToLower(tc.want)) { + t.Errorf("Render(%+v) = %q; want containing %q", tc.action, written, tc.want) + } + } +} + +func makeTestNav(t *testing.T, heading vmath.MagneticHeading) Nav { + t.Helper() + n := Nav{ + Rand: vrand.Make(), + } + n.FlightState.Heading = heading + n.FlightState.Altitude = 2000 + return n +} + +func TestConditionalDirectFixExecute(t *testing.T) { + n := makeTestNavWithRoute(t, "SAJUL") + action := ConditionalDirectFix{Fix: "SAJUL", Turn: av.TurnClosest} + action.Execute(n, Time{}, av.Temperature{}) + // After direct-fix, the first waypoint should be the target fix. + if len(n.Waypoints) == 0 || n.Waypoints[0].Fix != "SAJUL" { + t.Fatalf("expected first waypoint SAJUL, got %+v", n.Waypoints) + } +} + +func TestConditionalDirectFixRender(t *testing.T) { + cases := []struct { + action ConditionalDirectFix + want string + }{ + {ConditionalDirectFix{Fix: "SAJUL", Turn: av.TurnClosest}, "direct"}, + {ConditionalDirectFix{Fix: "SAJUL", Turn: av.TurnLeft}, "left"}, + {ConditionalDirectFix{Fix: "SAJUL", Turn: av.TurnRight}, "right"}, + } + r := vrand.Make() + for _, tc := range cases { + rt := &av.RadioTransmission{} + tc.action.Render(rt, r) + written := strings.ToLower(rt.Written(r)) + if !strings.Contains(written, strings.ToLower(tc.want)) { + t.Errorf("Render(%+v) = %q; want containing %q", tc.action, written, tc.want) + } + } +} + +// makeTestNavWithRoute returns a *Nav whose Waypoints contains a waypoint +// with the given fix name, suitable for calling DirectFix on it. +func makeTestNavWithRoute(t *testing.T, fix string) *Nav { + t.Helper() + f := NewArrivalFlight(t, ArrivalConfig{ + Waypoints: fix + "/star DETGY/star HAUPT/star", + DepartureAirport: "KMCO", + ArrivalAirport: "KJFK", + AircraftType: "A320", + InitialAltitude: 11000, + InitialSpeed: 250, + }) + return f.nav +} + +func TestConditionalSpeedExecute(t *testing.T) { + f := NewArrivalFlight(t, ArrivalConfig{ + Waypoints: "SAJUL/star DETGY/star HAUPT/star", + DepartureAirport: "KMCO", + ArrivalAirport: "KJFK", + AircraftType: "A320", + InitialAltitude: 11000, + InitialSpeed: 250, + }) + sr := av.MakeAtSpeedRestriction(210) + action := ConditionalSpeed{Restriction: sr} + action.Execute(f.nav, Time{}, av.Temperature{}) + if f.nav.Speed.Assigned == nil { + t.Fatalf("expected Speed.Assigned set, got nil") + } + if got, ok := f.nav.Speed.Assigned.ExactValue(); !ok || got != 210 { + t.Fatalf("expected 210, got ok=%v value=%v", ok, got) + } +} + +func TestConditionalMachExecute(t *testing.T) { + f := NewArrivalFlight(t, ArrivalConfig{ + Waypoints: "SAJUL/star DETGY/star HAUPT/star", + DepartureAirport: "KMCO", + ArrivalAirport: "KJFK", + AircraftType: "A320", + InitialAltitude: 30000, + InitialSpeed: 280, + }) + action := ConditionalMach{Mach: 0.78} + // Use a plausible high-altitude temperature (ISA at 30k ≈ -45°C). + action.Execute(f.nav, Time{}, av.MakeTemperatureFromCelsius(-45)) + + // AssignMach sets Speed.Assigned with IsMach=true. Assert on that surface. + if f.nav.Speed.Assigned == nil { + t.Fatalf("expected Speed.Assigned set, got nil") + } + if !f.nav.Speed.Assigned.IsMach { + t.Fatalf("expected mach restriction, got speed") + } +} + +func TestConditionalTriggered(t *testing.T) { + cases := []struct { + name string + kind ConditionalKind + trigger float32 + altitude float32 + rate float32 // vertical rate (positive = climb) + want bool + }{ + // --- ConditionalLeaving --- + {"LV climbing well past", ConditionalLeaving, 3000, 3200, +500, true}, + {"LV descending well past", ConditionalLeaving, 3000, 2800, -500, true}, + {"LV level at trigger", ConditionalLeaving, 3000, 3000, 0, false}, + {"LV within tolerance climbing", ConditionalLeaving, 3000, 3020, +500, false}, // <50ft past + {"LV 60ft past climbing", ConditionalLeaving, 3000, 3060, +500, true}, + {"LV 60ft below climbing (wrong dir)", ConditionalLeaving, 3000, 2940, +500, false}, + // --- ConditionalReaching --- + {"RC within 100ft", ConditionalReaching, 10000, 9950, +500, true}, + {"RC 50ft past still climbing", ConditionalReaching, 10000, 10050, +500, true}, + {"RC 200ft short climbing", ConditionalReaching, 10000, 9800, +500, false}, + {"RC leveled at target", ConditionalReaching, 10000, 10000, 0, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Build a minimal Nav — no need for full FlightTest here; only + // FlightState.{Altitude, AltitudeRate} are read. + var n Nav + n.FlightState.Altitude = tc.altitude + n.FlightState.AltitudeRate = tc.rate + pc := &PendingConditionalCommand{Kind: tc.kind, Altitude: tc.trigger} + if got := ConditionalTriggered(&n, pc); got != tc.want { + t.Errorf("want %v got %v (kind=%v trigger=%v alt=%v rate=%v)", + tc.want, got, tc.kind, tc.trigger, tc.altitude, tc.rate) + } + }) + } +} diff --git a/nav/nav.go b/nav/nav.go index 171bb38fa..c18a373ab 100644 --- a/nav/nav.go +++ b/nav/nav.go @@ -68,6 +68,12 @@ type Nav struct { PendingWaypointActionEvents []av.WaypointActionEvent Rand *rand.Rand + + // PendingConditionalCommand stores a single deferred LV/RC action + // (e.g., "leaving 3,000, fly heading 010"). Cleared when the trigger + // fires or when a new LV/RC command is installed. Not cleared on + // new altitude/heading/speed assignments or on handoff. + PendingConditionalCommand *PendingConditionalCommand } type UpdateResult struct { diff --git a/sim/command_parser.go b/sim/command_parser.go index 225539120..984459116 100644 --- a/sim/command_parser.go +++ b/sim/command_parser.go @@ -12,6 +12,7 @@ import ( av "github.com/mmp/vice/aviation" "github.com/mmp/vice/math" + "github.com/mmp/vice/nav" "github.com/mmp/vice/util" ) @@ -251,6 +252,99 @@ func parseSpeedUntil(untilStr string) *av.SpeedUntil { return &av.SpeedUntil{Fix: untilStr} } +// parseConditionalAltitude parses the altitude-encoding convention used +// by LV/RC commands: number × 100, with a carve-out for values that look +// like feet already (>600 and evenly divisible by 100). +func parseConditionalAltitude(s string) (float32, error) { + if s == "" { + return 0, ErrInvalidCommandSyntax + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, err + } + if n > 600 && n%100 == 0 { + return float32(n), nil + } + return float32(n * 100), nil +} + +// parseConditionalAction parses an inner command string (the right-hand +// side of LV/RC) into a typed ConditionalAction. Accepts only lateral and +// speed/mach actions; altitude-changing and unknown inners return +// ErrInvalidCommandSyntax. +// +// Grammar: +// +// H{hdg} → ConditionalHeading (closest turn) +// L{hdg} | R{hdg} → ConditionalHeading (left/right turn to heading) +// L{deg}D | R{deg}D → ConditionalHeading (turn N degrees) +// D{fix} → ConditionalDirectFix (closest) +// LD{fix} | RD{fix} → ConditionalDirectFix (left/right) +// S{spd} → ConditionalSpeed +// M{mach} → ConditionalMach (2-digit mach, e.g. M78 → 0.78) +func parseConditionalAction(s string) (nav.ConditionalAction, error) { + if len(s) < 2 { + return nil, ErrInvalidCommandSyntax + } + switch s[0] { + case 'H': + hdg, err := strconv.Atoi(s[1:]) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalHeading{Heading: hdg, Turn: av.TurnClosest}, nil + + case 'L', 'R': + turn := av.TurnLeft + if s[0] == 'R' { + turn = av.TurnRight + } + // LD{fix} / RD{fix} + if len(s) >= 5 && s[1] == 'D' { + return nav.ConditionalDirectFix{Fix: strings.ToUpper(s[2:]), Turn: turn}, nil + } + // LnnD / RnnD + if l := len(s); l > 2 && s[l-1] == 'D' { + deg, err := strconv.Atoi(s[1 : l-1]) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalHeading{ByDegrees: deg, Turn: turn}, nil + } + // L{hdg} / R{hdg} + hdg, err := strconv.Atoi(s[1:]) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalHeading{Heading: hdg, Turn: turn}, nil + + case 'D': + if len(s) < 4 { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalDirectFix{Fix: strings.ToUpper(s[1:]), Turn: av.TurnClosest}, nil + + case 'S': + sr, err := av.ParseSpeedRestriction(s[1:]) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalSpeed{Restriction: *sr}, nil + + case 'M': + if len(s) != 3 { + return nil, ErrInvalidCommandSyntax + } + mach, err := strconv.ParseFloat(s[1:], 32) + if err != nil { + return nil, ErrInvalidCommandSyntax + } + return nav.ConditionalMach{Mach: float32(mach) / 100.0}, nil + } + return nil, ErrInvalidCommandSyntax +} + // parseCompoundSpeed parses a compound speed command string like // "250+/UFIX1/210-/UFIX2/180+" into CompoundSpeedSegments. // The input is the part after 'S' (e.g., "250+/UFIX1/210-/UFIX2/180+"). @@ -745,6 +839,24 @@ func (s *Sim) runOneControlCommand(tcw TCW, callsign av.ADSBCallsign, command st } case 'L': + if strings.HasPrefix(command, "LV") { + if len(command) <= 2 { + return nil, ErrInvalidCommandSyntax + } + altStr, inner, ok := strings.Cut(command[2:], "/") + if !ok || altStr == "" || inner == "" { + return nil, ErrInvalidCommandSyntax + } + alt, err := parseConditionalAltitude(altStr) + if err != nil { + return nil, err + } + action, err := parseConditionalAction(inner) + if err != nil { + return nil, err + } + return s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, alt, action) + } 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' { @@ -792,6 +904,23 @@ func (s *Sim) runOneControlCommand(tcw TCW, callsign av.ADSBCallsign, command st return s.ResumeOwnNavigation(tcw, callsign) } else if command == "RST" { return s.RadarServicesTerminated(tcw, callsign) + } else if strings.HasPrefix(command, "RC") { + if len(command) <= 2 { + return nil, ErrInvalidCommandSyntax + } + altStr, inner, ok := strings.Cut(command[2:], "/") + if !ok || altStr == "" || inner == "" { + return nil, ErrInvalidCommandSyntax + } + alt, err := parseConditionalAltitude(altStr) + if err != nil { + return nil, err + } + action, err := parseConditionalAction(inner) + if err != nil { + return nil, err + } + return s.AssignConditional(tcw, callsign, nav.ConditionalReaching, alt, action) } else if len(command) >= 5 && command[1] == 'D' { return s.DirectFix(tcw, callsign, command[2:], av.TurnRight, delayReduction) } else if l := len(command); l > 2 && command[l-1] == 'D' { diff --git a/sim/commands.go b/sim/commands.go index 300da647d..6affe8f1c 100644 --- a/sim/commands.go +++ b/sim/commands.go @@ -11,6 +11,7 @@ import ( av "github.com/mmp/vice/aviation" "github.com/mmp/vice/math" + "github.com/mmp/vice/nav" "github.com/mmp/vice/wx" ) @@ -94,6 +95,77 @@ func (s *Sim) AssignCompoundSpeed(tcw TCW, callsign av.ADSBCallsign, segments [] }) } +// AssignConditional installs a deferred LV/RC action on the aircraft's +// nav state. Fires silently when sim.updateState observes the altitude +// trigger. Returns an UnableIntent if the trigger is not reachable from +// the aircraft's current vertical state; the outer error is reserved for +// lookup/authorization failures. +func (s *Sim) AssignConditional(tcw TCW, callsign av.ADSBCallsign, + kind nav.ConditionalKind, altitude float32, action nav.ConditionalAction) (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 { + if !triggerReachable(ac, kind, altitude) { + return av.MakeUnableIntent("unable. {alt} is out of our climb/descent path.", altitude) + } + ac.Nav.PendingConditionalCommand = &nav.PendingConditionalCommand{ + Kind: kind, + Altitude: altitude, + Action: action, + } + return av.ConditionalCommandIntent{ + Kind: kind, + Altitude: altitude, + Action: action, + } + }) +} + +// triggerReachable reports whether a LV/RC trigger altitude is +// reasonably reachable from the aircraft's current vertical state, +// allowing the controller command to be accepted. +// +// For ConditionalLeaving: accepted if the aircraft is within 500 ft of +// the trigger (so "leaving 3,000" works even for an aircraft at 3,050), +// or if the trigger lies between current altitude and assigned target. +// +// For ConditionalReaching: accepted if the trigger lies between current +// altitude and assigned target, or (if no target assigned) the aircraft +// is within 500 ft of the trigger. +func triggerReachable(ac *Aircraft, kind nav.ConditionalKind, trigger float32) bool { + cur := ac.Nav.FlightState.Altitude + target := ac.Nav.Altitude.Assigned + diff := math.Abs(cur - trigger) + switch kind { + case nav.ConditionalLeaving: + if diff <= 500 { + return true + } + if target == nil { + return false + } + return betweenAlt(trigger, cur, *target) + case nav.ConditionalReaching: + if target == nil { + return diff <= 500 + } + return betweenAlt(trigger, cur, *target) + } + return false +} + +// betweenAlt reports whether v lies between a and b (inclusive), in +// either ordering. +func betweenAlt(v, a, b float32) bool { + lo, hi := a, b + if lo > hi { + lo, hi = hi, lo + } + return v >= lo && v <= hi +} + func (s *Sim) MaintainSlowestPractical(tcw TCW, callsign av.ADSBCallsign) (av.CommandIntent, error) { s.mu.Lock(s.lg) defer s.mu.Unlock(s.lg) diff --git a/sim/control_test.go b/sim/control_test.go index 4efc311ce..0fc22a39f 100644 --- a/sim/control_test.go +++ b/sim/control_test.go @@ -4,12 +4,15 @@ package sim import ( + "errors" + "reflect" "testing" av "github.com/mmp/vice/aviation" "github.com/mmp/vice/log" "github.com/mmp/vice/math" "github.com/mmp/vice/nav" + "github.com/mmp/vice/rand" ) func TestParseHold(t *testing.T) { @@ -350,3 +353,373 @@ func TestRunOneControlCommandAtFixClearedStraightInApproach(t *testing.T) { t.Fatal("AtFixClearedRoute was not populated") } } + +func TestTriggerReachable(t *testing.T) { + cases := []struct { + name string + kind nav.ConditionalKind + trigger float32 + current float32 + assigned *float32 + want bool + }{ + // LV: within 500ft slack even if direction is wrong + {"LV aircraft at 3050 climbing past", nav.ConditionalLeaving, 3000, 3050, ptr[float32](5000), true}, + {"LV aircraft far past", nav.ConditionalLeaving, 3000, 5000, ptr[float32](7000), false}, + {"LV trigger in path", nav.ConditionalLeaving, 3000, 1000, ptr[float32](5000), true}, + {"LV no target, far from trigger", nav.ConditionalLeaving, 3000, 8000, nil, false}, + // RC: trigger must be between current and assigned target + {"RC target is trigger", nav.ConditionalReaching, 10000, 5000, ptr[float32](10000), true}, + {"RC trigger above target", nav.ConditionalReaching, 12000, 5000, ptr[float32](10000), false}, + {"RC no target but close", nav.ConditionalReaching, 10000, 9900, nil, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ac := &Aircraft{} + ac.Nav.FlightState.Altitude = tc.current + ac.Nav.Altitude.Assigned = tc.assigned + got := triggerReachable(ac, tc.kind, tc.trigger) + if got != tc.want { + t.Errorf("want %v got %v", tc.want, got) + } + }) + } +} + +func TestParseConditionalAltitude(t *testing.T) { + cases := []struct { + in string + want float32 + wantErr bool + }{ + {"30", 3000, false}, // hundreds-of-feet + {"130", 13000, false}, + {"100", 10000, false}, + {"1000", 1000, false}, // >600 && %100==0 → already feet + {"13000", 13000, false}, // ditto + {"", 0, true}, + {"abc", 0, true}, + } + for _, tc := range cases { + got, err := parseConditionalAltitude(tc.in) + if (err != nil) != tc.wantErr { + t.Errorf("parseConditionalAltitude(%q) err=%v wantErr=%v", tc.in, err, tc.wantErr) + continue + } + if !tc.wantErr && got != tc.want { + t.Errorf("parseConditionalAltitude(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func setupTestSimWithAircraftAt(t *testing.T, altitude, assigned float32) (*Sim, av.ADSBCallsign, TCW) { + t.Helper() + lg := log.New(true, "error", t.TempDir()) + callsign := av.ADSBCallsign("TEST123") + tcw := TCW("TCW1") + s := &Sim{ + State: &CommonState{ + DynamicState: DynamicState{ + CurrentConsolidation: map[TCW]*TCPConsolidation{ + tcw: {PrimaryTCP: "1A"}, + }, + }, + }, + Aircraft: map[av.ADSBCallsign]*Aircraft{ + callsign: { + ADSBCallsign: callsign, + ControllerFrequency: "1A", + Nav: nav.Nav{ + FlightState: nav.FlightState{ + Altitude: altitude, + }, + Altitude: nav.NavAltitude{ + Assigned: ptr[float32](assigned), + }, + }, + }, + }, + PendingContacts: map[TCP][]PendingContact{}, + PrivilegedTCWs: map[TCW]bool{tcw: true}, + lg: lg, + } + return s, callsign, tcw +} + +func TestAssignConditionalInstallsSlot(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) + action := nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest} + intent, err := s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, 3000, action) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if intent == nil { + t.Fatalf("expected non-nil intent") + } + if _, ok := intent.(av.ConditionalCommandIntent); !ok { + t.Fatalf("expected ConditionalCommandIntent, got %T", intent) + } + pc := s.Aircraft[callsign].Nav.PendingConditionalCommand + if pc == nil { + t.Fatalf("expected PendingConditionalCommand installed") + } + if pc.Altitude != 3000 { + t.Fatalf("wrong altitude: %v", pc.Altitude) + } + if pc.Kind != nav.ConditionalLeaving { + t.Fatalf("wrong kind: %v", pc.Kind) + } +} + +func TestAssignConditionalRejectsUnreachable(t *testing.T) { + // Aircraft at 5000 level (assigned also 5000); trigger 3000 -> unreachable. + s, callsign, tcw := setupTestSimWithAircraftAt(t, 5000, 5000) + action := nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest} + intent, err := s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, 3000, action) + if err != nil { + t.Fatalf("unexpected dispatch error: %v", err) + } + if _, ok := intent.(av.UnableIntent); !ok { + t.Fatalf("expected UnableIntent for unreachable trigger, got %T", intent) + } + if s.Aircraft[callsign].Nav.PendingConditionalCommand != nil { + t.Fatalf("expected no slot installed after unable") + } +} + +func TestAssignConditionalSupersedes(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) + first := nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest} + second := nav.ConditionalDirectFix{Fix: "AAC", Turn: av.TurnClosest} + if _, err := s.AssignConditional(tcw, callsign, nav.ConditionalLeaving, 3000, first); err != nil { + t.Fatalf("first assign: %v", err) + } + if _, err := s.AssignConditional(tcw, callsign, nav.ConditionalReaching, 6000, second); err != nil { + t.Fatalf("second assign: %v", err) + } + pc := s.Aircraft[callsign].Nav.PendingConditionalCommand + if pc == nil { + t.Fatalf("expected superseded slot, got nil") + } + if pc.Kind == nav.ConditionalLeaving { + t.Fatalf("old Leaving kind not replaced") + } + if pc.Kind != nav.ConditionalReaching || pc.Altitude != 6000 { + t.Fatalf("expected superseded slot: reaching 6000, got %+v", pc) + } +} + +func TestParseConditionalAction(t *testing.T) { + cases := []struct { + in string + wantType string // type name of returned ConditionalAction + wantProps map[string]any + wantErr bool + }{ + {"H010", "ConditionalHeading", map[string]any{"Heading": 10, "Turn": av.TurnClosest}, false}, + {"L100", "ConditionalHeading", map[string]any{"Heading": 100, "Turn": av.TurnLeft}, false}, + {"R100", "ConditionalHeading", map[string]any{"Heading": 100, "Turn": av.TurnRight}, false}, + {"L20D", "ConditionalHeading", map[string]any{"ByDegrees": 20, "Turn": av.TurnLeft}, false}, + {"R30D", "ConditionalHeading", map[string]any{"ByDegrees": 30, "Turn": av.TurnRight}, false}, + {"DAAC", "ConditionalDirectFix", map[string]any{"Fix": "AAC", "Turn": av.TurnClosest}, false}, + {"LDAAC", "ConditionalDirectFix", map[string]any{"Fix": "AAC", "Turn": av.TurnLeft}, false}, + {"RDAAC", "ConditionalDirectFix", map[string]any{"Fix": "AAC", "Turn": av.TurnRight}, false}, + {"S210", "ConditionalSpeed", nil, false}, + {"M78", "ConditionalMach", map[string]any{"Mach": float32(0.78)}, false}, + + // Rejections: altitude-changing inners, unknowns, malformed + {"C50", "", nil, true}, + {"CVS", "", nil, true}, + {"DVS", "", nil, true}, + {"X010", "", nil, true}, + {"", "", nil, true}, + {"H", "", nil, true}, + {"HXYZ", "", nil, true}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + got, err := parseConditionalAction(tc.in) + if (err != nil) != tc.wantErr { + t.Fatalf("parseConditionalAction(%q) err=%v wantErr=%v", tc.in, err, tc.wantErr) + } + if tc.wantErr { + return + } + typeName := reflect.TypeOf(got).Name() + if typeName != tc.wantType { + t.Fatalf("parseConditionalAction(%q) type = %s, want %s", tc.in, typeName, tc.wantType) + } + v := reflect.ValueOf(got) + for k, want := range tc.wantProps { + field := v.FieldByName(k) + if !field.IsValid() { + t.Errorf("no field %s on %s", k, typeName) + continue + } + if !reflect.DeepEqual(field.Interface(), want) { + t.Errorf("%s.%s = %v, want %v", typeName, k, field.Interface(), want) + } + } + }) + } +} + +func TestRunOneControlCommandLV(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) + intent, err := s.runOneControlCommand(tcw, callsign, "LV30/H010", 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := intent.(av.ConditionalCommandIntent); !ok { + t.Fatalf("expected ConditionalCommandIntent, got %T", intent) + } + ac := s.Aircraft[callsign] + if ac.Nav.PendingConditionalCommand == nil { + t.Fatalf("slot not installed") + } + if ac.Nav.PendingConditionalCommand.Altitude != 3000 { + t.Fatalf("wrong altitude %v", ac.Nav.PendingConditionalCommand.Altitude) + } +} + +func TestRunOneControlCommandLVRejectsMalformed(t *testing.T) { + cases := []struct { + cmd string + wantSyntax bool + }{ + {"LV", true}, // bare command, too short + {"LV30H010", true}, // missing slash + {"LV/H010", true}, // empty altitude + {"LV30/", true}, // empty inner + {"LVABC/H010", false}, // non-numeric altitude (strconv error) + {"LV30/X010", true}, // unknown inner command + {"LV30/C50", true}, // altitude-changing inner rejected by parseConditionalAction + } + for _, tc := range cases { + t.Run(tc.cmd, func(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 2000, 7000) + _, err := s.runOneControlCommand(tcw, callsign, tc.cmd, 0) + if err == nil { + t.Fatalf("expected error for %q, got nil", tc.cmd) + } + if tc.wantSyntax && !errors.Is(err, ErrInvalidCommandSyntax) { + t.Fatalf("expected ErrInvalidCommandSyntax for %q, got %v", tc.cmd, err) + } + }) + } +} + +func TestRunOneControlCommandRC(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 5000, 10000) + intent, err := s.runOneControlCommand(tcw, callsign, "RC100/DAAC", 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := intent.(av.ConditionalCommandIntent); !ok { + t.Fatalf("expected ConditionalCommandIntent, got %T", intent) + } + ac := s.Aircraft[callsign] + if ac.Nav.PendingConditionalCommand == nil { + t.Fatalf("slot not installed") + } + if ac.Nav.PendingConditionalCommand.Altitude != 10000 { + t.Fatalf("wrong altitude %v", ac.Nav.PendingConditionalCommand.Altitude) + } + if ac.Nav.PendingConditionalCommand.Kind != nav.ConditionalReaching { + t.Fatalf("wrong kind %v", ac.Nav.PendingConditionalCommand.Kind) + } +} + +func TestRunOneControlCommandRCRejectsMalformed(t *testing.T) { + cases := []struct { + cmd string + wantSyntax bool + }{ + {"RC", true}, // bare command, too short + {"RC100H010", true}, // missing slash + {"RC/H010", true}, // empty altitude + {"RC100/", true}, // empty inner + {"RCABC/H010", false}, // non-numeric altitude (strconv error) + {"RC100/X010", true}, // unknown inner command + {"RC100/C50", true}, // altitude-changing inner rejected by parseConditionalAction + } + for _, tc := range cases { + t.Run(tc.cmd, func(t *testing.T) { + s, callsign, tcw := setupTestSimWithAircraftAt(t, 5000, 10000) + _, err := s.runOneControlCommand(tcw, callsign, tc.cmd, 0) + if err == nil { + t.Fatalf("expected error for %q, got nil", tc.cmd) + } + if tc.wantSyntax && !errors.Is(err, ErrInvalidCommandSyntax) { + t.Fatalf("expected ErrInvalidCommandSyntax for %q, got %v", tc.cmd, err) + } + }) + } +} + +func TestFireConditionalIfTriggeredFiresAndClearsSlot(t *testing.T) { + // Aircraft climbing through 3000 with a pending LV 3000/H010 command. + s, callsign, _ := setupTestSimWithAircraftAt(t, 3100, 7000) + ac := s.Aircraft[callsign] + ac.NASFlightPlan = &NASFlightPlan{} // make IsAssociated() return true + ac.Nav.Rand = rand.Make() // needed by EnqueueHeading for pilot-delay jitter + ac.Nav.FlightState.AltitudeRate = 500 // climbing + ac.Nav.PendingConditionalCommand = &nav.PendingConditionalCommand{ + Kind: nav.ConditionalLeaving, + Altitude: 3000, + Action: nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest}, + } + + s.fireConditionalIfTriggered(ac, av.Temperature{}) + + if ac.Nav.PendingConditionalCommand != nil { + t.Fatalf("expected slot cleared after firing, still got %+v", ac.Nav.PendingConditionalCommand) + } + if hdg, ok := ac.Nav.AssignedHeading(); !ok || hdg != 10 { + t.Fatalf("expected assigned heading 10, got ok=%v hdg=%v", ok, hdg) + } +} + +func TestFireConditionalIfTriggeredHoldsSlotWhenNotTriggered(t *testing.T) { + // Aircraft at 2000 climbing — has not yet reached 3000 trigger. + s, callsign, _ := setupTestSimWithAircraftAt(t, 2000, 7000) + ac := s.Aircraft[callsign] + ac.NASFlightPlan = &NASFlightPlan{} // make IsAssociated() return true + ac.Nav.FlightState.AltitudeRate = 500 + pc := &nav.PendingConditionalCommand{ + Kind: nav.ConditionalLeaving, + Altitude: 3000, + Action: nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest}, + } + ac.Nav.PendingConditionalCommand = pc + + s.fireConditionalIfTriggered(ac, av.Temperature{}) + + if ac.Nav.PendingConditionalCommand != pc { + t.Fatalf("expected slot still installed (not triggered yet)") + } + if _, ok := ac.Nav.AssignedHeading(); ok { + t.Fatalf("expected no heading assigned before trigger fires") + } +} + +func TestFireConditionalIfTriggeredSkipsWhenUnassociated(t *testing.T) { + // Setup sim and aircraft state that WOULD trigger, but aircraft has no + // NASFlightPlan so IsAssociated() returns false. + s, callsign, _ := setupTestSimWithAircraftAt(t, 3100, 7000) + ac := s.Aircraft[callsign] + // NASFlightPlan is nil by default from setupTestSimWithAircraftAt — unassociated. + ac.Nav.FlightState.AltitudeRate = 500 + pc := &nav.PendingConditionalCommand{ + Kind: nav.ConditionalLeaving, + Altitude: 3000, + Action: nav.ConditionalHeading{Heading: 10, Turn: av.TurnClosest}, + } + ac.Nav.PendingConditionalCommand = pc + + s.fireConditionalIfTriggered(ac, av.Temperature{}) + + if ac.Nav.PendingConditionalCommand != pc { + t.Fatalf("expected slot preserved when unassociated") + } +} diff --git a/sim/sim.go b/sim/sim.go index d87fa3d86..7cb5234a4 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -981,6 +981,11 @@ func (s *Sim) updateState() { s.enqueuePilotTransmission(callsign, TCP(ac.ControllerFrequency), PendingTransmissionRequestVectors) } + if ac.Nav.PendingConditionalCommand != nil { + temp := s.wxModel.Lookup(ac.Nav.FlightState.Position, ac.Nav.FlightState.Altitude, s.State.SimTime.Time()).Temperature() + s.fireConditionalIfTriggered(ac, temp) + } + if ac.FirstSeen.IsZero() && s.isRadarVisible(ac) { ac.FirstSeen = s.State.SimTime } @@ -1464,6 +1469,24 @@ func (s *Sim) AnnotateFlightStrip(tcw TCW, acid ACID, annotations [9]string) err return nil } +// fireConditionalIfTriggered executes and clears the aircraft's pending +// conditional command if the trigger condition is now met. The slot is +// cleared BEFORE Execute runs so a follow-on conditional installed by +// Execute cannot fire on the same tick. temp is only consulted by the +// Mach variant; other actions ignore it. +func (s *Sim) fireConditionalIfTriggered(ac *Aircraft, temp av.Temperature) { + pc := ac.Nav.PendingConditionalCommand + if pc == nil || !ac.IsAssociated() { + return + } + if !nav.ConditionalTriggered(&ac.Nav, pc) { + return + } + action := pc.Action + ac.Nav.PendingConditionalCommand = nil + action.Execute(&ac.Nav, s.State.SimTime.NavTime(), temp) +} + func (s *Sim) GlobalMessage(tcw TCW, message string) { s.mu.Lock(s.lg) defer s.mu.Unlock(s.lg) diff --git a/stt/handlers.go b/stt/handlers.go index 0d1a56b24..06d640585 100644 --- a/stt/handlers.go +++ b/stt/handlers.go @@ -1463,4 +1463,304 @@ func registerAllCommands() { WithName("airport_in_sight_inquiry"), WithPriority(10), ) + + // === CONDITIONAL COMMANDS: LEAVING/PASSING {alt}, {inner} === + // Fires the inner command when aircraft crosses the given altitude. + + // LV{alt}/H{hdg}: "leaving three thousand fly heading 010" + registerSTTCommand( + "leaving|passing {altitude} fly heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("LV%d/H%03d", alt, hdg) }, + WithName("conditional_lv_fly_heading"), + WithPriority(13), + ) + registerSTTCommand( + "leaving|passing {altitude} heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("LV%d/H%03d", alt, hdg) }, + WithName("conditional_lv_heading"), + WithPriority(13), + ) + + // LV{alt}/L{hdg}, LV{alt}/R{hdg}: "leaving five thousand turn left 270" + registerSTTCommand( + "leaving|passing {altitude} [turn] [to] left {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("LV%d/L%03d", alt, hdg) }, + WithName("conditional_lv_turn_left_heading"), + WithPriority(13), + ) + registerSTTCommand( + "leaving|passing {altitude} [turn] [to] right {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("LV%d/R%03d", alt, hdg) }, + WithName("conditional_lv_turn_right_heading"), + WithPriority(13), + ) + + // LV{alt}/L{deg}D, LV{alt}/R{deg}D: "leaving three thousand turn left 20 degrees" + registerSTTCommand( + "leaving|passing {altitude} turn {degrees}", + func(alt int, dr degreesResult) string { + dir := "L" + if dr.direction == "right" { + dir = "R" + } + return fmt.Sprintf("LV%d/%s%dD", alt, dir, dr.degrees) + }, + WithName("conditional_lv_turn_degrees"), + WithPriority(13), + ) + + // LV{alt}/D{fix}, LV{alt}/LD{fix}, LV{alt}/RD{fix} + // SayAgainOnFail: when "leaving|passing {alt} direct|proceed" matches but + // {fix} can't be resolved, emit SAYAGAIN instead of silently falling through + // to the standalone_altitude handler (which would produce a misleading + // A{alt}). MinTokens(3) requires keyword+altitude+direct/proceed to all + // match before triggering — prevents false positives where "leave" or + // "passing" fuzzy-matches unrelated words. + registerSTTCommand( + "leaving|passing {altitude} direct|proceed [direct] [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("LV%d/D%s", alt, fix) }, + WithName("conditional_lv_direct"), + WithPriority(13), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), + ) + registerSTTCommand( + "leaving|passing {altitude} [proceed] left [turn] direct [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("LV%d/LD%s", alt, fix) }, + WithName("conditional_lv_left_direct"), + WithPriority(14), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), + ) + registerSTTCommand( + "leaving|passing {altitude} [proceed] right [turn] direct [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("LV%d/RD%s", alt, fix) }, + WithName("conditional_lv_right_direct"), + WithPriority(14), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), + ) + + // LV{alt}/S{spd}: "leaving five thousand reduce speed to 210" + registerSTTCommand( + "leaving|passing {altitude} reduce|slow [speed] [to] {speed}", + func(alt int, spd int) string { return fmt.Sprintf("LV%d/S%d", alt, spd) }, + WithName("conditional_lv_reduce_speed"), + WithPriority(13), + ) + registerSTTCommand( + "leaving|passing {altitude} maintain|increase [speed] [to] {speed}", + func(alt int, spd int) string { return fmt.Sprintf("LV%d/S%d", alt, spd) }, + WithName("conditional_lv_maintain_speed"), + WithPriority(13), + ) + + // LV{alt}/M{mach}: "leaving flight level 300 maintain mach 78" + registerSTTCommand( + "leaving|passing {altitude} maintain mach [point] {mach}", + func(alt int, mach int) string { return fmt.Sprintf("LV%d/M%d", alt, mach) }, + WithName("conditional_lv_maintain_mach"), + WithPriority(13), + ) + registerSTTCommand( + "leaving|passing {altitude} mach [point] {mach}", + func(alt int, mach int) string { return fmt.Sprintf("LV%d/M%d", alt, mach) }, + WithName("conditional_lv_mach"), + WithPriority(13), + ) + + // === CONDITIONAL COMMANDS: REACHING/LEVEL AT/ON REACHING {alt}, {inner} === + // Fires the inner command when aircraft first crosses within ~100 ft of the + // given altitude (ConditionalReaching). Mirror of the LV section above. + // + // "reaching|level at|on reaching" cannot be expressed as a single template + // because the template parser splits on spaces — "level at" and "on reaching" + // are two-word phrases. Instead each inner command is registered twice: + // - trigger_a: "[on] reaching {altitude} ..." (matches "reaching X" and "on reaching X") + // - trigger_b: "level [at] {altitude} ..." (matches "level X" and "level at X") + + // RC{alt}/H{hdg}: "reaching three thousand fly heading 010" + registerSTTCommand( + "[on] reaching {altitude} fly heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/H%03d", alt, hdg) }, + WithName("conditional_rc_fly_heading"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} fly heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/H%03d", alt, hdg) }, + WithName("conditional_rc_fly_heading_level"), + WithPriority(13), + ) + registerSTTCommand( + "[on] reaching {altitude} heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/H%03d", alt, hdg) }, + WithName("conditional_rc_heading"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} heading {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/H%03d", alt, hdg) }, + WithName("conditional_rc_heading_level"), + WithPriority(13), + ) + + // RC{alt}/L{hdg}, RC{alt}/R{hdg}: "reaching five thousand turn left 270" + registerSTTCommand( + "[on] reaching {altitude} [turn] [to] left {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/L%03d", alt, hdg) }, + WithName("conditional_rc_turn_left_heading"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} [turn] [to] left {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/L%03d", alt, hdg) }, + WithName("conditional_rc_turn_left_heading_level"), + WithPriority(13), + ) + registerSTTCommand( + "[on] reaching {altitude} [turn] [to] right {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/R%03d", alt, hdg) }, + WithName("conditional_rc_turn_right_heading"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} [turn] [to] right {heading}", + func(alt int, hdg int) string { return fmt.Sprintf("RC%d/R%03d", alt, hdg) }, + WithName("conditional_rc_turn_right_heading_level"), + WithPriority(13), + ) + + // RC{alt}/L{deg}D, RC{alt}/R{deg}D: "reaching three thousand turn left 20 degrees" + registerSTTCommand( + "[on] reaching {altitude} turn {degrees}", + func(alt int, dr degreesResult) string { + dir := "L" + if dr.direction == "right" { + dir = "R" + } + return fmt.Sprintf("RC%d/%s%dD", alt, dir, dr.degrees) + }, + WithName("conditional_rc_turn_degrees"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} turn {degrees}", + func(alt int, dr degreesResult) string { + dir := "L" + if dr.direction == "right" { + dir = "R" + } + return fmt.Sprintf("RC%d/%s%dD", alt, dir, dr.degrees) + }, + WithName("conditional_rc_turn_degrees_level"), + WithPriority(13), + ) + + // RC{alt}/D{fix}, RC{alt}/LD{fix}, RC{alt}/RD{fix} + // SayAgainOnFail: when "reaching|level at {alt} direct|proceed" matches but + // {fix} can't be resolved, emit SAYAGAIN instead of silently falling + // through to the standalone_altitude handler (which would produce a + // misleading A{alt}). MinTokens(3) requires keyword+altitude+direct/proceed + // to all match before triggering. + registerSTTCommand( + "[on] reaching {altitude} direct|proceed [direct] [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/D%s", alt, fix) }, + WithName("conditional_rc_direct"), + WithPriority(13), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), + ) + registerSTTCommand( + "level [at] {altitude} direct|proceed [direct] [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/D%s", alt, fix) }, + WithName("conditional_rc_direct_level"), + WithPriority(13), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), + ) + registerSTTCommand( + "[on] reaching {altitude} [proceed] left [turn] direct [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/LD%s", alt, fix) }, + WithName("conditional_rc_left_direct"), + WithPriority(14), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), + ) + registerSTTCommand( + "level [at] {altitude} [proceed] left [turn] direct [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/LD%s", alt, fix) }, + WithName("conditional_rc_left_direct_level"), + WithPriority(14), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), + ) + registerSTTCommand( + "[on] reaching {altitude} [proceed] right [turn] direct [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/RD%s", alt, fix) }, + WithName("conditional_rc_right_direct"), + WithPriority(14), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), + ) + registerSTTCommand( + "level [at] {altitude} [proceed] right [turn] direct [to] [at] {fix}", + func(alt int, fix string) string { return fmt.Sprintf("RC%d/RD%s", alt, fix) }, + WithName("conditional_rc_right_direct_level"), + WithPriority(14), + WithSayAgainOnFail(), + WithSayAgainMinTokens(3), + ) + + // RC{alt}/S{spd}: "reaching five thousand reduce speed to 210" + registerSTTCommand( + "[on] reaching {altitude} reduce|slow [speed] [to] {speed}", + func(alt int, spd int) string { return fmt.Sprintf("RC%d/S%d", alt, spd) }, + WithName("conditional_rc_reduce_speed"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} reduce|slow [speed] [to] {speed}", + func(alt int, spd int) string { return fmt.Sprintf("RC%d/S%d", alt, spd) }, + WithName("conditional_rc_reduce_speed_level"), + WithPriority(13), + ) + registerSTTCommand( + "[on] reaching {altitude} maintain|increase [speed] [to] {speed}", + func(alt int, spd int) string { return fmt.Sprintf("RC%d/S%d", alt, spd) }, + WithName("conditional_rc_maintain_speed"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} maintain|increase [speed] [to] {speed}", + func(alt int, spd int) string { return fmt.Sprintf("RC%d/S%d", alt, spd) }, + WithName("conditional_rc_maintain_speed_level"), + WithPriority(13), + ) + + // RC{alt}/M{mach}: "reaching flight level 300 maintain mach 78" + registerSTTCommand( + "[on] reaching {altitude} maintain mach [point] {mach}", + func(alt int, mach int) string { return fmt.Sprintf("RC%d/M%d", alt, mach) }, + WithName("conditional_rc_maintain_mach"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} maintain mach [point] {mach}", + func(alt int, mach int) string { return fmt.Sprintf("RC%d/M%d", alt, mach) }, + WithName("conditional_rc_maintain_mach_level"), + WithPriority(13), + ) + registerSTTCommand( + "[on] reaching {altitude} mach [point] {mach}", + func(alt int, mach int) string { return fmt.Sprintf("RC%d/M%d", alt, mach) }, + WithName("conditional_rc_mach"), + WithPriority(13), + ) + registerSTTCommand( + "level [at] {altitude} mach [point] {mach}", + func(alt int, mach int) string { return fmt.Sprintf("RC%d/M%d", alt, mach) }, + WithName("conditional_rc_mach_level"), + WithPriority(13), + ) } diff --git a/stt/matcher.go b/stt/matcher.go index 0ef5c494b..e28d709da 100644 --- a/stt/matcher.go +++ b/stt/matcher.go @@ -37,12 +37,23 @@ func (m *literalMatcher) match(tokens []Token, pos int, ac Aircraft, skipWords [ return matchResult{} } - // Skip filler words + // Skip filler words, but don't skip a token that exactly matches one of this + // matcher's target keywords. This allows filler-listed words like "leaving" to + // be recognized when used as actual command keywords (e.g., in "leaving|passing"). for pos < len(tokens) { text := strings.ToLower(tokens[pos].Text) if IsFillerWord(text) { - pos++ - continue + isOwnKeyword := false + for _, kw := range m.keywords { + if text == kw { + isOwnKeyword = true + break + } + } + if !isOwnKeyword { + pos++ + continue + } } break } diff --git a/stt/parse.go b/stt/parse.go index 58aafad3c..ec4f61e37 100644 --- a/stt/parse.go +++ b/stt/parse.go @@ -36,6 +36,30 @@ func ParseCommands(tokens []Token, ac Aircraft) ([]string, float64) { continue } + // Check for "leaving|passing {altitude} ..." pattern BEFORE filler-word skip. + // "leaving" is a filler word (to prevent fuzzy match with "heading"), but it + // also starts the conditional LV command. When followed by an altitude, treat + // it as a command keyword rather than filler. + if (tokens[pos].Text == "leaving" || tokens[pos].Text == "passing") && + pos+1 < len(tokens) && looksLikeAltitude(tokens[pos+1]) { + logLocalStt(" found %q before altitude at position %d, treating as LV command start", tokens[pos].Text, pos) + match, newPos := matchCommandNew(tokens, pos, ac, isThen, excludeCategories) + if newPos > pos { + logLocalStt(" matched LV command: %q (conf=%.2f, consumed=%d)", match.Command, match.Confidence, newPos-pos) + matchedAny = true + if match.Command != "" { + commands = append(commands, match.Command) + totalConf += match.Confidence + if category := getCommandCategory(match.Command); category != "" { + excludeCategories[category] = true + } + } + pos = newPos + isThen = false + continue + } + } + // Skip filler words if IsFillerWord(tokens[pos].Text) { logLocalStt(" skipping filler word: %q", tokens[pos].Text) diff --git a/stt/provider_test.go b/stt/provider_test.go index 31f6111aa..06ab3a853 100644 --- a/stt/provider_test.go +++ b/stt/provider_test.go @@ -4173,3 +4173,190 @@ func TestNegativeWithoutCallsign(t *testing.T) { }) } } + +func TestSTTLeavingPatterns(t *testing.T) { + tests := []struct { + name string + transcript string + expected string + }{ + { + name: "leaving thousand fly heading", + transcript: "Delta 43 leaving three thousand fly heading zero one zero", + expected: "DAL43 LV30/H010", + }, + { + name: "passing thousand right heading", + transcript: "American 17 passing one three thousand right one zero zero", + expected: "AAL17 LV130/R100", + }, + { + name: "leaving thousand turn left heading", + transcript: "Delta 43 leaving five thousand turn left two seven zero", + expected: "DAL43 LV50/L270", + }, + { + name: "leaving thousand turn left degrees", + transcript: "Delta 43 leaving three thousand turn left twenty degrees", + expected: "DAL43 LV30/L20D", + }, + { + name: "leaving thousand turn right degrees", + transcript: "Delta 43 leaving three thousand turn right thirty degrees", + expected: "DAL43 LV30/R30D", + }, + { + name: "leaving thousand direct fix", + transcript: "Delta 43 leaving three thousand direct alpha alpha charlie", + expected: "DAL43 LV30/DAAC", + }, + { + name: "leaving thousand reduce speed", + transcript: "Delta 43 leaving five thousand reduce speed to two one zero", + expected: "DAL43 LV50/S210", + }, + { + name: "leaving thousand left direct fix", + transcript: "Delta 43 leaving three thousand left direct alpha alpha charlie", + expected: "DAL43 LV30/LDAAC", + }, + { + name: "leaving thousand right direct fix", + transcript: "Delta 43 leaving three thousand right direct alpha alpha charlie", + expected: "DAL43 LV30/RDAAC", + }, + { + name: "leaving thousand maintain speed", + transcript: "Delta 43 leaving five thousand maintain speed two five zero", + expected: "DAL43 LV50/S250", + }, + { + name: "leaving flight level maintain mach", + transcript: "American 17 leaving flight level three zero zero maintain mach point seven eight", + expected: "AAL17 LV300/M78", + }, + } + + aircraft := map[string]Aircraft{ + "Delta 43": { + Callsign: "DAL43", + Altitude: 2000, + State: "departure", + Fixes: map[string]string{"alpha alpha charlie": "AAC"}, + }, + "American 17": { + Callsign: "AAL17", + Altitude: 10000, + State: "departure", + }, + } + + provider := NewTranscriber(nil) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := provider.DecodeTranscript(aircraft, tt.transcript, "") + if err != nil { + t.Fatalf("DecodeTranscript: %v", err) + } + if got != tt.expected { + t.Errorf("got %q, want %q", got, tt.expected) + } + }) + } +} + +func TestSTTReachingPatterns(t *testing.T) { + tests := []struct { + name string + transcript string + expected string + }{ + { + name: "reaching thousand fly heading", + transcript: "Delta 43 reaching one zero thousand fly heading zero one zero", + expected: "DAL43 RC100/H010", + }, + { + name: "level at thousand heading", + transcript: "Delta 43 level at five thousand heading two seven zero", + expected: "DAL43 RC50/H270", + }, + { + name: "on reaching thousand turn left heading", + transcript: "Delta 43 on reaching three thousand turn left two seven zero", + expected: "DAL43 RC30/L270", + }, + { + name: "reaching thousand turn right heading", + transcript: "Delta 43 reaching five thousand turn right one eight zero", + expected: "DAL43 RC50/R180", + }, + { + name: "reaching thousand turn left degrees", + transcript: "Delta 43 reaching three thousand turn left twenty degrees", + expected: "DAL43 RC30/L20D", + }, + { + name: "reaching thousand turn right degrees", + transcript: "Delta 43 reaching three thousand turn right thirty degrees", + expected: "DAL43 RC30/R30D", + }, + { + name: "reaching thousand direct fix", + transcript: "Delta 43 reaching three thousand direct alpha alpha charlie", + expected: "DAL43 RC30/DAAC", + }, + { + name: "reaching thousand left direct fix", + transcript: "Delta 43 reaching three thousand left direct alpha alpha charlie", + expected: "DAL43 RC30/LDAAC", + }, + { + name: "reaching thousand right direct fix", + transcript: "Delta 43 reaching three thousand right direct alpha alpha charlie", + expected: "DAL43 RC30/RDAAC", + }, + { + name: "reaching thousand reduce speed", + transcript: "Delta 43 reaching five thousand reduce speed to two one zero", + expected: "DAL43 RC50/S210", + }, + { + name: "reaching thousand maintain speed", + transcript: "Delta 43 reaching five thousand maintain speed two five zero", + expected: "DAL43 RC50/S250", + }, + { + name: "reaching flight level maintain mach", + transcript: "American 17 reaching flight level three zero zero maintain mach point seven eight", + expected: "AAL17 RC300/M78", + }, + } + + aircraft := map[string]Aircraft{ + "Delta 43": { + Callsign: "DAL43", + Altitude: 15000, + State: "arrival", + Fixes: map[string]string{"alpha alpha charlie": "AAC"}, + }, + "American 17": { + Callsign: "AAL17", + Altitude: 35000, + State: "arrival", + }, + } + + provider := NewTranscriber(nil) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := provider.DecodeTranscript(aircraft, tt.transcript, "") + if err != nil { + t.Fatalf("DecodeTranscript: %v", err) + } + if got != tt.expected { + t.Errorf("got %q, want %q", got, tt.expected) + } + }) + } +} diff --git a/whatsnew.md b/whatsnew.md index 82d8773f3..20d3bedbb 100644 --- a/whatsnew.md +++ b/whatsnew.md @@ -8,6 +8,7 @@ - Fixed a few bugs with "at {fix}, cleared straight in {approach}" - Added "after {fix}, climb/descend and maintain {altitude}" - Added "after {fix}, reduce/maintain/increase {speed}" + - Added "leaving/reaching {altitude}, {action}" controller commands. Examples: `LV30/H010` ("leaving 3,000, fly heading 010"), `RC100/DAAC` ("reaching 10,000, direct AAC"). Supported inner actions: headings, turns by degrees, direct-to-fix, speed, and mach. - Added "speed {speed1} until {fix1}, then {speed2} until {fix2}, then {speed3}", etc. - Added "cross {fix} {miles} miles {direction} of {fix}" - Added "good rate" for climbs/descents: "descend and maintain 3,000, good rate through 5,000", etc.