Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
88317f2
docs: design spec for LV/RC conditional altitude commands
Jud6969 Apr 21, 2026
6066ad2
docs: implementation plan for LV/RC conditional altitude commands
Jud6969 Apr 21, 2026
e67dddb
nav: add ConditionalAction interface and PendingConditionalCommand slot
Jud6969 Apr 21, 2026
6f2b981
nav: add ConditionalHeading action with Execute and Render
Jud6969 Apr 21, 2026
55ac8c9
nav: test ConditionalHeading right-turn variants
Jud6969 Apr 21, 2026
590b80d
nav: add ConditionalDirectFix action
Jud6969 Apr 21, 2026
cbbf54f
nav: add ConditionalSpeed and ConditionalMach; thread temperature thr…
Jud6969 Apr 21, 2026
360b0be
nav: add ConditionalTriggered predicate
Jud6969 Apr 21, 2026
4f92ba7
sim: add triggerReachable helper for LV/RC conditional commands
Jud6969 Apr 21, 2026
bb0b466
sim: add parseConditionalAltitude helper
Jud6969 Apr 21, 2026
5724bba
sim: add parseConditionalAction for LV/RC inner commands
Jud6969 Apr 21, 2026
5d1b0d2
aviation: add ConditionalCommandIntent; nav: alias enum to aviation
Jud6969 Apr 21, 2026
94e6f36
aviation: loop seeds in TestConditionalCommandIntentRender
Jud6969 Apr 21, 2026
0305ce2
sim: add AssignConditional method for LV/RC commands
Jud6969 Apr 21, 2026
c9f9cdf
sim: lock AssignConditional and relocate to Assign* cluster
Jud6969 Apr 21, 2026
a495ccd
sim: dispatch LV{alt}/{inner} as conditional-leaving command
Jud6969 Apr 21, 2026
3959d61
sim: tighten LV malformed-input handling and test assertions
Jud6969 Apr 21, 2026
2073c6c
sim: dispatch RC{alt}/{inner} as conditional-reaching command
Jud6969 Apr 21, 2026
f510ceb
sim: fire LV/RC conditional commands when altitude trigger is met
Jud6969 Apr 21, 2026
d9de8b7
sim: clarify fireConditionalIfTriggered comment
Jud6969 Apr 21, 2026
fabd822
stt: recognize "leaving|passing {alt}, {inner}" → LV{alt}/{inner}
Jud6969 Apr 21, 2026
7da135d
stt: cover the remaining LV inner commands with tests
Jud6969 Apr 21, 2026
c6fc1ca
stt: recognize "reaching|level at|on reaching {alt}, {inner}" → RC{al…
Jud6969 Apr 21, 2026
e856548
docs: whatsnew entry for LV/RC conditional commands
Jud6969 Apr 21, 2026
de27d96
stt: emit SAYAGAIN when LV/RC direct-fix can't resolve
Jud6969 Apr 21, 2026
44a8f3a
Merge remote-tracking branch 'upstream/master' into leaving-reaching-…
Jud6969 Apr 23, 2026
c29d188
untrack docs/superpowers/ planning docs
Jud6969 Apr 23, 2026
e6ab096
Merge remote-tracking branch 'upstream/master' into leaving-reaching-…
Jud6969 Apr 23, 2026
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
40 changes: 40 additions & 0 deletions aviation/intent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 38 additions & 0 deletions aviation/intent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
178 changes: 178 additions & 0 deletions nav/conditional.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading