From 0d3ab5b158eefe79fcb50f63fd6e8015c41f6521 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:37:29 -0400 Subject: [PATCH 01/27] aircraft: add PilotAltim fields and SimulatePilotAltimeter toggle Sentinel-based: PilotAltim==0 means feature inactive for that aircraft. FacilityAdaptation.SimulatePilotAltimeter gates everything else, default off. Co-Authored-By: Claude Opus 4.7 --- sim/aircraft.go | 5 +++++ sim/stars.go | 1 + 2 files changed, 6 insertions(+) diff --git a/sim/aircraft.go b/sim/aircraft.go index 14aac4693..7d7f37106 100644 --- a/sim/aircraft.go +++ b/sim/aircraft.go @@ -144,6 +144,11 @@ type Aircraft struct { // field is in sight. Set to zero after the check (requested or given up) to prevent retries. VisualApproachRequestDistance float32 + // Altimeter setting simulation. PilotAltim == 0 is the "feature off / + // not initialized" sentinel; bias math short-circuits to 0 in that case. + PilotAltim float32 + PilotAltimSetAt Time + TouchAndGosRemaining int // >0 means pattern aircraft; decremented each lap } diff --git a/sim/stars.go b/sim/stars.go index f6e4d2c56..e17056213 100644 --- a/sim/stars.go +++ b/sim/stars.go @@ -52,6 +52,7 @@ type FacilityAdaptation struct { Range float32 `json:"range"` Scratchpads map[string]string `json:"scratchpads"` SignificantPoints map[string]SignificantPoint `json:"significant_points"` + SimulatePilotAltimeter bool `json:"simulate_pilot_altimeter"` // Airpsace filters Filters struct { From fc37b3896f870d3a37f7c209f74cce8309c95ef8 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 06:42:25 -0400 Subject: [PATCH 02/27] sim: add altimeter bias helpers altimBiasFeet computes (actual - pilot) * 1000 with sentinel for zero pilot. nearestActualAltim picks closest METAR-reporting station, 0 on empty map. Unit tests cover sign correctness, sentinel, magnitude, and empty METAR. --- sim/altimeter.go | 40 ++++++++++++++++++++++++++++++++++++++ sim/altimeter_test.go | 45 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 sim/altimeter.go create mode 100644 sim/altimeter_test.go diff --git a/sim/altimeter.go b/sim/altimeter.go new file mode 100644 index 000000000..21c91605b --- /dev/null +++ b/sim/altimeter.go @@ -0,0 +1,40 @@ +// sim/altimeter.go +// Copyright(c) 2022-2025 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package sim + +import ( + av "github.com/mmp/vice/aviation" + "github.com/mmp/vice/math" +) + +// altimBiasFeet returns the altitude error caused by the pilot's altimeter +// setting differing from the local actual. Positive bias means the aircraft +// flies *higher* than assigned (pilot set too low). Negative means lower. +func altimBiasFeet(nearestActualInHg, pilotInHg float32) float32 { + if pilotInHg == 0 { + return 0 + } + return (nearestActualInHg - pilotInHg) * 1000 +} + +// nearestActualAltim returns the altimeter (inHg) at the METAR-reporting +// station geographically closest to pos. Returns 0 if no usable METAR is +// available; callers treat 0 as "skip bias entirely". +func (s *Sim) nearestActualAltim(pos math.Point2LL) float32 { + var best float32 + bestDist := float32(1e30) + for icao, m := range s.State.METAR { + ap, ok := av.DB.Airports[icao] + if !ok { + continue + } + d := math.NMDistance2LL(pos, ap.Location) + if d < bestDist { + bestDist = d + best = m.Altimeter_inHg() + } + } + return best +} diff --git a/sim/altimeter_test.go b/sim/altimeter_test.go new file mode 100644 index 000000000..8fbf51591 --- /dev/null +++ b/sim/altimeter_test.go @@ -0,0 +1,45 @@ +// sim/altimeter_test.go +// Copyright(c) 2022-2025 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package sim + +import ( + "testing" + + "github.com/mmp/vice/math" + "github.com/mmp/vice/wx" +) + +func TestAltimBiasFeet(t *testing.T) { + tests := []struct { + name string + nearestActualInHg float32 + pilotInHg float32 + want float32 + }{ + {"zero pilot short-circuits", 30.05, 0, 0}, + {"equal values give zero bias", 30.05, 30.05, 0}, + {"pilot too low yields positive bias", 30.10, 30.00, 100}, + {"pilot too high yields negative bias", 30.00, 30.10, -100}, + {"realistic small mismatch", 30.05, 30.03, 20}, + {"large geographic delta", 30.20, 29.80, 400}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := altimBiasFeet(tc.nearestActualInHg, tc.pilotInHg) + if math.Abs(got-tc.want) > 0.01 { + t.Errorf("altimBiasFeet(%v, %v) = %v, want %v", + tc.nearestActualInHg, tc.pilotInHg, got, tc.want) + } + }) + } +} + +func TestNearestActualAltimEmptyMap(t *testing.T) { + s := &Sim{State: &CommonState{DynamicState: DynamicState{METAR: map[string]wx.METAR{}}}} + got := s.nearestActualAltim(math.Point2LL{-73.78, 40.64}) // KJFK area + if got != 0 { + t.Errorf("nearestActualAltim with empty map = %v, want 0", got) + } +} From 19f6f6a90d156a8de983e92139843c291122dbaf Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 06:56:27 -0400 Subject: [PATCH 03/27] nav: plumb altimBiasFeet parameter through Update call chain Adds altimBiasFeet float32 to Nav.Update and Nav.UpdateWithWeather; applied unconditionally to targetAltitude before updateAltitude. All existing call sites pass 0, so no behavior changes yet. --- nav/alt_test.go | 16 ++++++++-------- nav/commands_test.go | 4 ++-- nav/lateral.go | 11 ++++++----- nav/nav_test.go | 2 +- nav/pt_test.go | 6 +++--- sim/aircraft.go | 4 ++-- sim/sim.go | 2 +- sim/spawn_departures.go | 4 ++-- 8 files changed, 25 insertions(+), 24 deletions(-) diff --git a/nav/alt_test.go b/nav/alt_test.go index 9cf3a2aa3..0bea57b1b 100644 --- a/nav/alt_test.go +++ b/nav/alt_test.go @@ -36,12 +36,12 @@ func TestAssignAltitudeDelaysVerticalGuidance(t *testing.T) { } wxs := f.weather(f.nav.FlightState.Altitude) - f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, f.simTime, nil) + f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, 0, f.simTime, nil) f.AssertLevelFlight() f.simTime = f.nav.Altitude.ActivateAt wxs = f.weather(f.nav.FlightState.Altitude) - f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, f.simTime, nil) + f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, 0, f.simTime, nil) if f.nav.Altitude.ActiveAssigned == nil || *f.nav.Altitude.ActiveAssigned != 3000 { t.Fatalf("expected ActiveAssigned=3000 after delay, got %v", f.nav.Altitude.ActiveAssigned) @@ -70,7 +70,7 @@ func TestAssignAltitudeKeepsPreviousActiveAltitudeDuringDelay(t *testing.T) { } f.simTime = f.nav.Altitude.ActivateAt - f.nav.UpdateWithWeather(f.callsign, f.weather(f.nav.FlightState.Altitude), &f.fp, f.simTime, nil) + f.nav.UpdateWithWeather(f.callsign, f.weather(f.nav.FlightState.Altitude), &f.fp, 0, f.simTime, nil) target, _, _ = f.nav.TargetAltitude() if target != 3000 { t.Fatalf("expected new assigned altitude 3000 after delay, got %.0f", target) @@ -89,7 +89,7 @@ func TestAssignAltitudeKeepsSTARDescentDuringDelay(t *testing.T) { for range 300 { wxs := f.weather(f.nav.FlightState.Altitude) - f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, f.simTime, nil) + f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, 0, f.simTime, nil) f.simTime = f.simTime.Add(time.Second) if f.nav.FlightState.AltitudeRate < -50 { break @@ -153,7 +153,7 @@ func TestExpediteDuringAssignedAltitudeDelay(t *testing.T) { } f.simTime = f.nav.Altitude.ActivateAt.Add(time.Second) - f.nav.UpdateWithWeather(f.callsign, f.weather(f.nav.FlightState.Altitude), &f.fp, f.simTime, nil) + f.nav.UpdateWithWeather(f.callsign, f.weather(f.nav.FlightState.Altitude), &f.fp, 0, f.simTime, nil) if f.nav.Altitude.Rate != RateExpedite { t.Fatalf("expected expedite rate after delayed activation, got %v", f.nav.Altitude.Rate) } @@ -616,7 +616,7 @@ func TestAltitudeAfterSpeedDelaysAfterSpeedReached(t *testing.T) { for range 300 { wxs := f.weather(f.nav.FlightState.Altitude) - f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, f.simTime, nil) + f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, 0, f.simTime, nil) f.simTime = f.simTime.Add(time.Second) if f.nav.Altitude.AfterSpeed == nil { break @@ -637,7 +637,7 @@ func TestAltitudeAfterSpeedDelaysAfterSpeedReached(t *testing.T) { f.AssertLevelFlight() f.simTime = f.nav.Altitude.ActivateAt.Add(time.Second) - f.nav.UpdateWithWeather(f.callsign, f.weather(f.nav.FlightState.Altitude), &f.fp, f.simTime, nil) + f.nav.UpdateWithWeather(f.callsign, f.weather(f.nav.FlightState.Altitude), &f.fp, 0, f.simTime, nil) if f.nav.Altitude.ActiveAssigned == nil || *f.nav.Altitude.ActiveAssigned != 3000 { t.Fatalf("expected ActiveAssigned=3000 after delayed activation, got %v", f.nav.Altitude.ActiveAssigned) } @@ -747,7 +747,7 @@ func TestGoodRateDescentFasterThanNormal(t *testing.T) { runForTicks := func(f *FlightTest, ticks int) { for range ticks { wxs := f.weather(f.nav.FlightState.Altitude) - f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, f.simTime, nil) + f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, 0, f.simTime, nil) f.simTime = f.simTime.Add(1e9) } } diff --git a/nav/commands_test.go b/nav/commands_test.go index b8130dd04..1e8dbf24a 100644 --- a/nav/commands_test.go +++ b/nav/commands_test.go @@ -180,7 +180,7 @@ func TestExpectDirectReducesDelay(t *testing.T) { // Wait for heading to take effect for i := 0; i < 10; i++ { wxs := fNoExpect.weather(fNoExpect.nav.FlightState.Altitude) - fNoExpect.nav.UpdateWithWeather(fNoExpect.callsign, wxs, &fNoExpect.fp, fNoExpect.simTime, nil) + fNoExpect.nav.UpdateWithWeather(fNoExpect.callsign, wxs, &fNoExpect.fp, 0, fNoExpect.simTime, nil) fNoExpect.simTime = fNoExpect.simTime.Add(1e9) // 1 second } fNoExpect.nav.DirectFix("DETGY", av.TurnClosest, fNoExpect.simTime, 0) @@ -191,7 +191,7 @@ func TestExpectDirectReducesDelay(t *testing.T) { fExpect.nav.AssignHeading(math.MagneticHeading(360), av.TurnClosest, fExpect.simTime, 0) for i := 0; i < 10; i++ { wxs := fExpect.weather(fExpect.nav.FlightState.Altitude) - fExpect.nav.UpdateWithWeather(fExpect.callsign, wxs, &fExpect.fp, fExpect.simTime, nil) + fExpect.nav.UpdateWithWeather(fExpect.callsign, wxs, &fExpect.fp, 0, fExpect.simTime, nil) fExpect.simTime = fExpect.simTime.Add(1e9) } fExpect.nav.ExpectDirect("DETGY") diff --git a/nav/lateral.go b/nav/lateral.go index 20c216306..17122d1f2 100644 --- a/nav/lateral.go +++ b/nav/lateral.go @@ -120,14 +120,14 @@ func (nav *Nav) Check(lg *log.Logger) { } } -func (nav *Nav) Update(callsign string, model *wx.Model, fp *av.FlightPlan, simTime Time, bravo *av.AirspaceGrid) UpdateResult { +func (nav *Nav) Update(callsign string, model *wx.Model, fp *av.FlightPlan, altimBiasFeet float32, simTime Time, bravo *av.AirspaceGrid) UpdateResult { // Perform single weather lookup at the start wxs := model.Lookup(nav.FlightState.Position, nav.FlightState.Altitude, simTime.Time()) - return nav.UpdateWithWeather(callsign, wxs, fp, simTime, bravo) + return nav.UpdateWithWeather(callsign, wxs, fp, altimBiasFeet, simTime, bravo) } // UpdateWithWeather is a helper for simulations that use pre-fetched weather -func (nav *Nav) UpdateWithWeather(callsign string, wxs wx.Sample, fp *av.FlightPlan, simTime Time, bravo *av.AirspaceGrid) UpdateResult { +func (nav *Nav) UpdateWithWeather(callsign string, wxs wx.Sample, fp *av.FlightPlan, altimBiasFeet float32, simTime Time, bravo *av.AirspaceGrid) UpdateResult { nav.PendingWaypointActionEvents = nil nav.activatePendingAltitude(simTime) @@ -139,6 +139,7 @@ func (nav *Nav) UpdateWithWeather(callsign string, wxs wx.Sample, fp *av.FlightP nav.FlightState.BankAngle, nav.FlightState.AltitudeRate) targetAltitude, altitudeRate, geometricDescent := nav.TargetAltitude() + targetAltitude += altimBiasFeet deltaKts, slowingTo250 := nav.updateAirspeed(callsign, targetAltitude, geometricDescent, fp, wxs, simTime, bravo) nav.updateAltitude(callsign, targetAltitude, altitudeRate, geometricDescent, deltaKts, slowingTo250, wxs, simTime) nav.updateHeading(callsign, wxs, simTime) @@ -722,7 +723,7 @@ func (nav *Nav) shouldTurnForOutbound(p math.Point2LL, hdg math.MagneticHeading, // Don't simulate the turn longer than it will take to do it. n := int(1 + turnAngle/3) for range n { - nav2.UpdateWithWeather("", wxs, nil, Time{}, nil) + nav2.UpdateWithWeather("", wxs, nil, 0, Time{}, nil) curDist := math.SignedPointLineDistance(math.LL2NM(nav2.FlightState.Position, nav2.FlightState.NmPerLongitude), p0, p1) @@ -778,7 +779,7 @@ func (nav *Nav) shouldTurnToIntercept(p0 math.Point2LL, hdg math.MagneticHeading n := int(1 + turnAngle) lastDist := initialDist for range n { - nav2.UpdateWithWeather("", wxs, nil, Time{}, nil) + nav2.UpdateWithWeather("", wxs, nil, 0, Time{}, nil) curDist := math.SignedPointLineDistance(math.LL2NM(nav2.FlightState.Position, nav2.FlightState.NmPerLongitude), p0nm, p1) intercepted := math.Abs(curDist) < 0.02 diff --git a/nav/nav_test.go b/nav/nav_test.go index 231a984ac..b22255b4e 100644 --- a/nav/nav_test.go +++ b/nav/nav_test.go @@ -311,7 +311,7 @@ func (f *FlightTest) Run() { for f.tick = 0; f.tick < f.maxTicks; f.tick++ { wxs := f.weather(f.nav.FlightState.Altitude) - passedWp := f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, f.simTime, nil).PassedWaypoint + passedWp := f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, 0, f.simTime, nil).PassedWaypoint if passedWp != nil { f.passed = append(f.passed, passedWp.Fix) diff --git a/nav/pt_test.go b/nav/pt_test.go index 5bdf96e95..1f1fb0a14 100644 --- a/nav/pt_test.go +++ b/nav/pt_test.go @@ -146,7 +146,7 @@ func TestStandard45ProcedureTurnCompletes(t *testing.T) { f := makePTFlight(t, "FORMU/pt45/flyover ZIVUX WENGA", 3000, 180) wxs := f.weather(f.nav.FlightState.Altitude) - f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, f.simTime, nil) + f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, 0, f.simTime, nil) f.simTime = f.simTime.Add(time.Second) f.nav.flyProcedureTurnIfNecessary() @@ -180,7 +180,7 @@ func TestRacetrackPTCreatesManeuvers(t *testing.T) { f := makePTFlight(t, "FORMU/hilpt4.0nm/flyover ZIVUX WENGA", 3000, 180) wxs := f.weather(f.nav.FlightState.Altitude) - f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, f.simTime, nil) + f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, 0, f.simTime, nil) f.simTime = f.simTime.Add(time.Second) f.nav.flyProcedureTurnIfNecessary() @@ -231,7 +231,7 @@ func TestProcedureTurnDescendsToExitAltitude(t *testing.T) { f := makePTFlight(t, "FORMU/pt45/pta2000/flyover ZIVUX WENGA", 3000, 180) wxs := f.weather(f.nav.FlightState.Altitude) - f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, f.simTime, nil) + f.nav.UpdateWithWeather(f.callsign, wxs, &f.fp, 0, f.simTime, nil) f.simTime = f.simTime.Add(time.Second) f.nav.flyProcedureTurnIfNecessary() diff --git a/sim/aircraft.go b/sim/aircraft.go index 7d7f37106..e2f11bf8c 100644 --- a/sim/aircraft.go +++ b/sim/aircraft.go @@ -291,12 +291,12 @@ func (ac *Aircraft) TAS(temp av.Temperature) float32 { /////////////////////////////////////////////////////////////////////////// // Navigation and simulation -func (ac *Aircraft) Update(model *wx.Model, simTime Time, bravo *av.AirspaceGrid, lg *log.Logger) nav.UpdateResult { +func (ac *Aircraft) Update(model *wx.Model, altimBiasFeet float32, simTime Time, bravo *av.AirspaceGrid, lg *log.Logger) nav.UpdateResult { if lg != nil { lg = lg.With(slog.String("adsb_callsign", string(ac.ADSBCallsign))) } - navUpdate := ac.Nav.Update(string(ac.ADSBCallsign), model, &ac.FlightPlan, simTime.NavTime(), bravo) + navUpdate := ac.Nav.Update(string(ac.ADSBCallsign), model, &ac.FlightPlan, altimBiasFeet, simTime.NavTime(), bravo) if navUpdate.PassedWaypoint != nil && lg != nil { lg.Debug("passed", slog.Any("waypoint", navUpdate.PassedWaypoint)) } diff --git a/sim/sim.go b/sim/sim.go index 68c1b3b92..cd41f36d3 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -968,7 +968,7 @@ func (s *Sim) updateState() { continue } - updateResult := ac.Update(s.wxModel, s.State.SimTime, s.bravoAirspace, nil /* s.lg*/) + updateResult := ac.Update(s.wxModel, 0, s.State.SimTime, s.bravoAirspace, nil /* s.lg*/) passedWaypoint := updateResult.PassedWaypoint s.refreshSeenTraffic(ac) diff --git a/sim/spawn_departures.go b/sim/spawn_departures.go index 717ec57c3..5bdf11a34 100644 --- a/sim/spawn_departures.go +++ b/sim/spawn_departures.go @@ -713,7 +713,7 @@ func makeDepartureAircraft(ac *Aircraft, simTime Time, model *wx.Model, r *rand. start := ac.Position() d.MinSeparation = 120 * time.Second // just in case for i := range 120 { - simAc.Update(model, simTime, nil, nil /* lg */) + simAc.Update(model, 0, simTime, nil, nil /* lg */) // We need 6,000' and airborne, but we'll add a bit of slop if simAc.IsAirborne() && math.NMDistance2LL(start, simAc.Position()) > 7500*math.FeetToNauticalMiles { d.MinSeparation = time.Duration(i) * time.Second @@ -886,7 +886,7 @@ func (s *Sim) createUncontrolledVFRDeparture(depart, arrive, fleet string, route simNav.FlightState.Altitude, simTime.Time()) for i := range 3 * 60 * 60 { // limit to 3 hours of sim time, just in case if wp := simNav.UpdateWithWeather("", prespawnWxs, &simFP, - simTime.NavTime(), nil).PassedWaypoint; wp != nil { + 0, simTime.NavTime(), nil).PassedWaypoint; wp != nil { if wp.Delete() { return ac, rwy.Id, nil } From 4bf756d3864e42cb49861857009c08ba4afceb39 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:00:55 -0400 Subject: [PATCH 04/27] sim: compute altimeter bias per tick and pass to aircraft Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bias is gated on SimulatePilotAltimeter, IsAirborne, and Altitude < 18000. Below toggle is off and no aircraft have PilotAltim set, so all biases are 0 — behavior unchanged until spawn-init wires PilotAltim. --- sim/sim.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sim/sim.go b/sim/sim.go index cd41f36d3..fc3437381 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -968,7 +968,14 @@ func (s *Sim) updateState() { continue } - updateResult := ac.Update(s.wxModel, 0, s.State.SimTime, s.bravoAirspace, nil /* s.lg*/) + var bias float32 + if s.State.FacilityAdaptation.SimulatePilotAltimeter && + ac.Nav.IsAirborne() && + ac.Altitude() < 18000 { + actual := s.nearestActualAltim(ac.Position()) + bias = altimBiasFeet(actual, ac.PilotAltim) + } + updateResult := ac.Update(s.wxModel, bias, s.State.SimTime, s.bravoAirspace, nil /* s.lg*/) passedWaypoint := updateResult.PassedWaypoint s.refreshSeenTraffic(ac) From 4bafd1e65fadb7112591b8a20b5e6642f769657e Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:07:16 -0400 Subject: [PATCH 05/27] sim: initialize PilotAltim at aircraft spawn Hybrid rule: departures use field METAR; arrivals/IFR/VFR-with-FF use nearest METAR; fresh VFR overflights get a 30% chance of a random station-within-100NM altimeter (simulating stale upstream setting). --- sim/altimeter.go | 82 +++++++++++++++++++++++++++++++++++++++++++ sim/altimeter_test.go | 68 +++++++++++++++++++++++++++++++++++ sim/spawn.go | 1 + 3 files changed, 151 insertions(+) diff --git a/sim/altimeter.go b/sim/altimeter.go index 21c91605b..b2e90256d 100644 --- a/sim/altimeter.go +++ b/sim/altimeter.go @@ -9,6 +9,88 @@ import ( "github.com/mmp/vice/math" ) +// initPilotAltim sets ac.PilotAltim and ac.PilotAltimSetAt according to the +// hybrid spawn rule. No-op if SimulatePilotAltimeter is off. +// +// Categories: +// - Departure: local field altimeter (or nearest METAR if airport has no METAR) +// - Arrival / IFR overflight / VFR with flight-following: nearest METAR at spawn +// - VFR overflight without flight-following: 70% nearest, 30% random within 100 NM +func (s *Sim) initPilotAltim(ac *Aircraft) { + if !s.State.FacilityAdaptation.SimulatePilotAltimeter { + return + } + + pos := ac.Nav.FlightState.Position + + wrongEligible := ac.TypeOfFlight == av.FlightTypeOverflight && + ac.FlightPlan.Rules == av.FlightRulesVFR && + !ac.RequestedFlightFollowing + + if ac.TypeOfFlight == av.FlightTypeDeparture { + // Departure: use the departure airport's METAR if available. + if dep := ac.FlightPlan.DepartureAirport; dep != "" { + if m, ok := s.State.METAR[dep]; ok { + ac.PilotAltim = m.Altimeter_inHg() + ac.PilotAltimSetAt = s.State.SimTime + return + } + } + // Fallback to nearest METAR. + ac.PilotAltim = s.nearestActualAltim(pos) + ac.PilotAltimSetAt = s.State.SimTime + return + } + + if wrongEligible && s.Rand.Float32() < 0.30 { + // 30% chance: pick a random METAR within 100 NM. + if alt, ok := s.randomMETARWithin(pos, 100); ok { + ac.PilotAltim = alt + ac.PilotAltimSetAt = s.State.SimTime + return + } + } + + // Default: nearest METAR. + ac.PilotAltim = s.nearestActualAltim(pos) + ac.PilotAltimSetAt = s.State.SimTime +} + +// randomMETARWithin returns the altimeter from a uniformly random METAR +// station within rangeNM of pos, excluding the closest one (so the result +// represents a "stale from a different airport" setting). Returns ok=false +// if there are fewer than two stations in range. +func (s *Sim) randomMETARWithin(pos math.Point2LL, rangeNM float32) (float32, bool) { + type station struct { + alt float32 + dist float32 + } + var inRange []station + for icao, m := range s.State.METAR { + ap, ok := av.DB.Airports[icao] + if !ok { + continue + } + d := math.NMDistance2LL(pos, ap.Location) + if d <= rangeNM { + inRange = append(inRange, station{m.Altimeter_inHg(), d}) + } + } + if len(inRange) < 2 { + return 0, false + } + // Drop the nearest (we want a *different* station). + nearestIdx := 0 + for i := 1; i < len(inRange); i++ { + if inRange[i].dist < inRange[nearestIdx].dist { + nearestIdx = i + } + } + inRange = append(inRange[:nearestIdx], inRange[nearestIdx+1:]...) + pick := inRange[s.Rand.Intn(len(inRange))] + return pick.alt, true +} + // altimBiasFeet returns the altitude error caused by the pilot's altimeter // setting differing from the local actual. Positive bias means the aircraft // flies *higher* than assigned (pilot set too low). Negative means lower. diff --git a/sim/altimeter_test.go b/sim/altimeter_test.go index 8fbf51591..f3632a256 100644 --- a/sim/altimeter_test.go +++ b/sim/altimeter_test.go @@ -7,10 +7,51 @@ package sim import ( "testing" + av "github.com/mmp/vice/aviation" "github.com/mmp/vice/math" "github.com/mmp/vice/wx" ) +// knownTestAirportLocations maps ICAO codes used in tests to approximate +// lat/lon coordinates so nearestActualAltim can find them in av.DB. +var knownTestAirportLocations = map[string]math.Point2LL{ + "KJFK": {-73.78, 40.64}, + "KLGA": {-73.87, 40.77}, + "KEWR": {-74.17, 40.69}, +} + +func newTestSimWithMETAR(t *testing.T, settings map[string]float32) *Sim { + t.Helper() + metar := make(map[string]wx.METAR) + + // Ensure av.DB is initialised and contains the airports we need. + if av.DB == nil { + av.DB = &av.StaticDatabase{Airports: map[string]av.FAAAirport{}} + } + for icao, altInHg := range settings { + // wx.METAR.Altimeter is in hPa; Altimeter_inHg() returns 0.02953 * Altimeter. + // So set Altimeter = altInHg / 0.02953 to get the desired inHg value. + metar[icao] = wx.METAR{ + ICAO: icao, + Altimeter: altInHg / 0.02953, + } + // Register a stub airport entry so av.DB.Airports lookups succeed. + if _, exists := av.DB.Airports[icao]; !exists { + loc, ok := knownTestAirportLocations[icao] + if !ok { + loc = math.Point2LL{} // zero-island as fallback + } + av.DB.Airports[icao] = av.FAAAirport{Location: loc} + } + } + t.Cleanup(func() { + for icao := range settings { + delete(av.DB.Airports, icao) + } + }) + return &Sim{State: &CommonState{DynamicState: DynamicState{METAR: metar}}} +} + func TestAltimBiasFeet(t *testing.T) { tests := []struct { name string @@ -43,3 +84,30 @@ func TestNearestActualAltimEmptyMap(t *testing.T) { t.Errorf("nearestActualAltim with empty map = %v, want 0", got) } } + +func TestInitPilotAltimDisabledIsNoop(t *testing.T) { + s := &Sim{ + State: &CommonState{ + DynamicState: DynamicState{ + METAR: map[string]wx.METAR{"KJFK": {Altimeter: 30.05 / 0.02953}}, + }, + FacilityAdaptation: FacilityAdaptation{SimulatePilotAltimeter: false}, + }, + } + ac := &Aircraft{} + s.initPilotAltim(ac) + if ac.PilotAltim != 0 { + t.Errorf("toggle off: PilotAltim = %v, want 0", ac.PilotAltim) + } +} + +func TestInitPilotAltimSetsCorrectForArrival(t *testing.T) { + s := newTestSimWithMETAR(t, map[string]float32{"KJFK": 30.05}) + s.State.FacilityAdaptation.SimulatePilotAltimeter = true + ac := &Aircraft{TypeOfFlight: av.FlightTypeArrival} + ac.Nav.FlightState.Position = math.Point2LL{-73.78, 40.64} // near KJFK + s.initPilotAltim(ac) + if math.Abs(ac.PilotAltim-30.05) > 0.001 { + t.Errorf("arrival PilotAltim = %v, want 30.05", ac.PilotAltim) + } +} diff --git a/sim/spawn.go b/sim/spawn.go index d624d3b14..ecac1e971 100644 --- a/sim/spawn.go +++ b/sim/spawn.go @@ -394,6 +394,7 @@ func (s *Sim) addAircraftNoLock(ac Aircraft) { } s.Aircraft[ac.ADSBCallsign] = &ac + s.initPilotAltim(&ac) ac.Nav.Prespawn = s.prespawn && ac.FlightPlan.Rules == av.FlightRulesVFR From 6e2e88ab1565462fee4210bd0b73c79691696aab Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:12:49 -0400 Subject: [PATCH 06/27] sim: add altimeter readback transmission type and intent PendingTransmissionAltimeterReadback carries hundredths-of-inHg in PendingContact.AltimeterHundredths. Render-switch mutates ac.PilotAltim at audible-event time, matching the TrafficInSight pattern. --- aviation/intent.go | 15 +++++++++++++++ sim/radio.go | 11 +++++++++++ 2 files changed, 26 insertions(+) diff --git a/aviation/intent.go b/aviation/intent.go index 99aae47cb..bfc53bd87 100644 --- a/aviation/intent.go +++ b/aviation/intent.go @@ -1018,6 +1018,21 @@ func (m MixUpIntent) Render(rt *RadioTransmission, r *rand.Rand) { rt.Add("sorry, was that for {callsign}?", csArg) } +/////////////////////////////////////////////////////////////////////////// +// AltimeterReadback Intent + +// AltimeterReadbackIntent renders a pilot's readback of an altimeter setting +// issued by the controller, e.g., "altimeter three zero zero two, American 123". +type AltimeterReadbackIntent struct { + SettingHundredths int // e.g., 3002 for 30.02 +} + +func (a AltimeterReadbackIntent) Render(rt *RadioTransmission, r *rand.Rand) { + whole := a.SettingHundredths / 100 + hundredths := a.SettingHundredths % 100 + rt.Add("[{num} {num}|altimeter {num} {num}|roger {num} {num}]", whole, hundredths) +} + /////////////////////////////////////////////////////////////////////////// // LookForFieldIntent diff --git a/sim/radio.go b/sim/radio.go index ea0015485..355c18d36 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 + PendingTransmissionAltimeterReadback // After controller issues "altimeter X.XX" ) // FutureFrequencyChange represents a pilot switching to a new frequency. @@ -76,6 +77,7 @@ type PendingContact struct { HasQueuedEmergency bool // For departures: trigger emergency after contact PrebuiltTransmission *av.RadioTransmission // For emergency transmissions: pre-built message FirstInFacility bool // For arrivals: first contact in this TRACON facility + AltimeterHundredths int // For PendingTransmissionAltimeterReadback: e.g., 3002 for 30.02 } // hasPendingCheckIn reports whether the aircraft has a pending arrival or @@ -420,6 +422,15 @@ func (s *Sim) GenerateContactTransmission(pc *PendingContact) (spokenText, writt s.runEmergencyStage(ac) } + case PendingTransmissionAltimeterReadback: + setting := pc.AltimeterHundredths + ac.PilotAltim = float32(setting) / 100 + ac.PilotAltimSetAt = s.State.SimTime + whole := setting / 100 + hundredths := setting % 100 + rt = av.MakeContactTransmission("[{num} {num}|altimeter {num} {num}|roger {num} {num}]", + whole, hundredths) + case PendingTransmissionTrafficInSight: rt = av.MakeContactTransmission("[we've got the traffic|we have the traffic in sight|traffic in sight now]") From 77f6ea31c2b6072bc2cd422564bf8232890f611a Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:17:24 -0400 Subject: [PATCH 07/27] sim: handle altimeter setting command from controllers Enqueues a PendingTransmissionAltimeterReadback when the toggle is on and the aircraft is on a controller frequency. Silent no-op when toggle off. Co-Authored-By: Claude Opus 4.7 --- sim/radio.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/sim/radio.go b/sim/radio.go index 355c18d36..951a8b4bf 100644 --- a/sim/radio.go +++ b/sim/radio.go @@ -331,6 +331,25 @@ func (s *Sim) enqueueEmergencyTransmission(callsign av.ADSBCallsign, tcp TCP, rt }) } +// handleAltimeterSetting processes an "altimeter X.XX" command issued by a +// controller. If the feature toggle is off, silently accepts the command and +// no-ops. Otherwise enqueues a pilot readback that will mutate PilotAltim +// when rendered. +func (s *Sim) handleAltimeterSetting(ac *Aircraft, settingHundredths int) { + if !s.State.FacilityAdaptation.SimulatePilotAltimeter { + return + } + if ac.ControllerFrequency == "" { + return + } + s.addPendingContact(PendingContact{ + ADSBCallsign: ac.ADSBCallsign, + TCP: TCP(ac.ControllerFrequency), + Type: PendingTransmissionAltimeterReadback, + AltimeterHundredths: settingHundredths, + }) +} + // cancelPendingInitialContact removes any pending Departure or Arrival contact // for the given aircraft. Called when a controller issues a command to an // aircraft that hasn't checked in yet, preventing stale check-ins. From f8565b76b39997825f684599a73e9b402a57b6a3 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:23:13 -0400 Subject: [PATCH 08/27] stt: extract altimeter setting value from transmissions Replace stripAltimeterSuffix with extractAltimeterSuffix that returns the parsed hundredths-of-inHg. Range-gated 2500-3200 to reject implausible numbers (e.g., headings). Caller captures the value but does not yet dispatch it (next commit). --- stt/provider.go | 73 ++++++++++++++++++++++++++++++++++++++------ stt/provider_test.go | 35 +++++++++++++++++++++ 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/stt/provider.go b/stt/provider.go index 8a49a1afd..f35390b24 100644 --- a/stt/provider.go +++ b/stt/provider.go @@ -1,6 +1,7 @@ package stt import ( + "strconv" "strings" "time" @@ -1035,7 +1036,11 @@ func containsGreeting(tokens []Token) bool { func stripInformational(tokens []Token) []Token { tokens = stripPositionIDPrefix(tokens) tokens = stripRadarContactPrefix(tokens) - tokens = stripAltimeterSuffix(tokens) + var altimSetting int + var altimOK bool + tokens, altimSetting, altimOK = extractAltimeterSuffix(tokens) + _ = altimSetting // wired up by Task 9 + _ = altimOK return tokens } @@ -1093,10 +1098,15 @@ func stripRadarContactPrefix(tokens []Token) []Token { return tokens } -// stripAltimeterSuffix removes an altimeter setting from the end of the -// token stream. Controllers often append "(airport) altimeter (setting)" -// as informational; it is not an actionable command. -func stripAltimeterSuffix(tokens []Token) []Token { +// extractAltimeterSuffix removes an altimeter setting from the end of the +// token stream and returns the parsed value (hundredths of inHg, e.g., 3002 +// for 30.02). Returns ok=false if no altimeter setting is found at the end. +// +// Recognized forms (after the optional "altimeter" keyword): +// - one number token: "3002" → 3002 +// - two number tokens: "30 02" → 3002 (or "29 95" → 2995) +// - spelled-out digits parsed by the upstream tokenizer. +func extractAltimeterSuffix(tokens []Token) ([]Token, int, bool) { for i, t := range tokens { if strings.ToLower(t.Text) != "altimeter" { continue @@ -1104,18 +1114,63 @@ func stripAltimeterSuffix(tokens []Token) []Token { if i+1 >= len(tokens) || tokens[i+1].Type != TokenNumber { continue } - if i+2 < len(tokens) { + + // Peek ahead: must be the trailing region of the transmission. + // Either one number token at the very end, or two number tokens at the end. + var settingTokens []Token + switch len(tokens) - i { + case 2: // "altimeter 3002" + settingTokens = tokens[i+1 : i+2] + case 3: // "altimeter 30 02" + if tokens[i+2].Type != TokenNumber { + continue + } + settingTokens = tokens[i+1 : i+3] + default: + continue + } + + hundredths, ok := parseAltimeterTokens(settingTokens) + if !ok { continue } + + // Trim the optional "(airport)" prefix word if present, to mirror + // the legacy stripAltimeterSuffix behavior. start := i if start > 0 && tokens[start-1].Type == TokenWord && !IsCommandKeyword(strings.ToLower(tokens[start-1].Text)) { start-- } - logLocalStt("stripped altimeter suffix: %d tokens", len(tokens)-start) - return tokens[:start] + logLocalStt("extracted altimeter suffix: %d hundredths, %d tokens consumed", + hundredths, len(tokens)-start) + return tokens[:start], hundredths, true } - return tokens + return tokens, 0, false +} + +// parseAltimeterTokens converts one or two number tokens to hundredths-of-inHg. +// "3002" → 3002; "30" + "02" → 3002. +func parseAltimeterTokens(toks []Token) (int, bool) { + switch len(toks) { + case 1: + n, err := strconv.Atoi(toks[0].Text) + if err != nil || n < 2500 || n > 3200 { + return 0, false + } + return n, true + case 2: + whole, err := strconv.Atoi(toks[0].Text) + if err != nil || whole < 25 || whole > 32 { + return 0, false + } + hundredths, err := strconv.Atoi(toks[1].Text) + if err != nil || hundredths < 0 || hundredths > 99 { + return 0, false + } + return whole*100 + hundredths, true + } + return 0, false } // logging helpers diff --git a/stt/provider_test.go b/stt/provider_test.go index 34cbb9405..f00b11cae 100644 --- a/stt/provider_test.go +++ b/stt/provider_test.go @@ -1949,6 +1949,12 @@ func TestNormalizeTranscript(t *testing.T) { } } +// tokenize is a test helper that runs NormalizeTranscript + Tokenize on a +// raw string, mirroring the real processing pipeline. +func tokenize(s string) []Token { + return Tokenize(NormalizeTranscript(s)) +} + func TestTokenize(t *testing.T) { tests := []struct { input []string @@ -1972,6 +1978,35 @@ func TestTokenize(t *testing.T) { } } +func TestExtractAltimeterSuffix(t *testing.T) { + tests := []struct { + name string + input string + wantHundredths int + wantOK bool + }{ + {"altimeter four-digit", "altimeter 3002", 3002, true}, + {"altimeter spaced", "altimeter 30 02", 3002, true}, + {"altimeter spelled-out", "altimeter three zero zero two", 3002, true}, + {"altimeter spoken thirty oh two", "altimeter thirty oh two", 3002, true}, + {"no altimeter", "turn left heading 270", 0, false}, + {"altimeter alone", "altimeter", 0, false}, + {"junk after altimeter number", "altimeter 3002 climb", 0, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tokens := tokenize(tc.input) + _, hundredths, ok := extractAltimeterSuffix(tokens) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if ok && hundredths != tc.wantHundredths { + t.Errorf("hundredths = %d, want %d", hundredths, tc.wantHundredths) + } + }) + } +} + // Benchmark for performance verification func BenchmarkDecodeTranscript(b *testing.B) { From 1538165feb38fc6249e169fa97627c4f0fcf73e7 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:32:04 -0400 Subject: [PATCH 09/27] stt+sim: dispatch ALT command to handleAltimeterSetting Controller transmissions ending in 'altimeter X.XX' now produce an ALT/ synthetic command, which the sim dispatches to handleAltimeterSetting on the addressed aircraft. Co-Authored-By: Claude Sonnet 4.6 --- sim/command_parser.go | 11 ++++++++++ stt/provider.go | 20 ++++++++++++++----- ...hirty_five_descend_and_maintain_one_s.json | 2 +- ...en_ninety_one_turn_right_heading_one_.json | 2 +- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/sim/command_parser.go b/sim/command_parser.go index 174f05ad7..57ac81b46 100644 --- a/sim/command_parser.go +++ b/sim/command_parser.go @@ -457,6 +457,17 @@ func (s *Sim) runOneControlCommand(tcw TCW, callsign av.ADSBCallsign, command st return s.AirportAdvisory(tcw, callsign, oclock, miles) } else if letter, ok := strings.CutPrefix(command, "ATIS/"); ok { return s.ATISCommand(tcw, callsign, letter) + } else if rest, ok := strings.CutPrefix(command, "ALT/"); ok { + // ALT/ — altimeter setting in hundredths of inHg (e.g. "ALT/3002" for 30.02). + // Produced by the STT pipeline when the controller says "altimeter X.XX". + setting, err := strconv.Atoi(rest) + if err != nil { + return nil, nil // silently ignore malformed + } + if ac, ok := s.Aircraft[callsign]; ok { + s.handleAltimeterSetting(ac, setting) + } + return nil, nil } else { components := strings.Split(command, "/") if len(components) != 2 || len(components[1]) == 0 { diff --git a/stt/provider.go b/stt/provider.go index f35390b24..3f818f296 100644 --- a/stt/provider.go +++ b/stt/provider.go @@ -1,6 +1,7 @@ package stt import ( + "fmt" "strconv" "strings" "time" @@ -165,7 +166,9 @@ func (p *Transcriber) decodeInternal( } // Strip informational phrases (position ID prefix, radar contact, altimeter setting) - commandTokens = stripInformational(commandTokens) + var altimSetting int + var altimOK bool + commandTokens, altimSetting, altimOK = stripInformational(commandTokens) // If no tokens remain after stripping, controller just identified themselves. // For VFR aircraft, treat this as an implicit "go ahead" — the pilot is @@ -207,6 +210,13 @@ func (p *Transcriber) decodeInternal( logLocalStt("validation errors: %v", validation.Errors) } + // Append ALT/ synthetic command when an altimeter setting suffix + // was extracted. This routes to handleAltimeterSetting in the sim layer. + if altimOK { + validation.ValidCommands = append(validation.ValidCommands, fmt.Sprintf("ALT/%d", altimSetting)) + logLocalStt("appended altimeter command ALT/%d", altimSetting) + } + // Compute overall confidence confidence := callsignConfidence if len(commands) > 0 { @@ -1033,15 +1043,15 @@ func containsGreeting(tokens []Token) bool { // stripInformational applies all informational prefix/suffix strippers in sequence: // position ID prefix, radar contact prefix, and altimeter setting suffix. -func stripInformational(tokens []Token) []Token { +// Returns the stripped tokens plus the altimeter setting in hundredths of inHg +// (e.g. 3002 for 30.02) and ok=true if an altimeter suffix was found. +func stripInformational(tokens []Token) ([]Token, int, bool) { tokens = stripPositionIDPrefix(tokens) tokens = stripRadarContactPrefix(tokens) var altimSetting int var altimOK bool tokens, altimSetting, altimOK = extractAltimeterSuffix(tokens) - _ = altimSetting // wired up by Task 9 - _ = altimOK - return tokens + return tokens, altimSetting, altimOK } // stripPositionIDPrefix removes a controller position identification prefix diff --git a/stt/tests/delta_seven_thirty_five_descend_and_maintain_one_s.json b/stt/tests/delta_seven_thirty_five_descend_and_maintain_one_s.json index 51be13509..ec4186eec 100644 --- a/stt/tests/delta_seven_thirty_five_descend_and_maintain_one_s.json +++ b/stt/tests/delta_seven_thirty_five_descend_and_maintain_one_s.json @@ -15,7 +15,7 @@ "processor": "GPU: Apple M4 Pro, 14 CPU / 20 GPU cores, 48GB", "whisper_model": "ggml-medium.en-jlvatc-q5_0.bin", "callsign": "DAL735", - "command": "D160", + "command": "D160 ALT/2979", "stt_aircraft": { "American 48": { "Callsign": "AAL48", diff --git a/stt/tests/jetblue_sixteen_ninety_one_turn_right_heading_one_.json b/stt/tests/jetblue_sixteen_ninety_one_turn_right_heading_one_.json index 2eaf91450..588013ab9 100644 --- a/stt/tests/jetblue_sixteen_ninety_one_turn_right_heading_one_.json +++ b/stt/tests/jetblue_sixteen_ninety_one_turn_right_heading_one_.json @@ -15,7 +15,7 @@ "processor": "GPU: Apple M4 Pro, 14 CPU / 20 GPU cores, 48GB", "whisper_model": "ggml-medium.en-jlvatc-q5_0.bin", "callsign": "JBU1691", - "command": "R180 D130", + "command": "R180 D130 ALT/3019", "stt_aircraft": { "American 10": { "Callsign": "AAL10", From 7e18e5651bbb7c884d4eb8da9302be0200079dd8 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 07:47:04 -0400 Subject: [PATCH 10/27] test: end-to-end altimeter bias and readback correction Spawns aircraft near KJFK with PilotAltim=30.05, teleports near KABE (altim 29.95), verifies altitude drifts to ~4900, issues 'altimeter 29.95', verifies aircraft drifts back to ~5000. --- sim/altimeter_integration_test.go | 119 ++++++++++++++++++++++++++++++ sim/altimeter_test.go | 1 + 2 files changed, 120 insertions(+) create mode 100644 sim/altimeter_integration_test.go diff --git a/sim/altimeter_integration_test.go b/sim/altimeter_integration_test.go new file mode 100644 index 000000000..c988faf1b --- /dev/null +++ b/sim/altimeter_integration_test.go @@ -0,0 +1,119 @@ +// sim/altimeter_integration_test.go +// Copyright(c) 2022-2025 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package sim + +import ( + "testing" + "time" + + av "github.com/mmp/vice/aviation" + "github.com/mmp/vice/math" + "github.com/mmp/vice/rand" + "github.com/mmp/vice/wx" +) + +// newTestAircraftAtAltitude returns a minimal Aircraft suitable for the +// altitude-bias integration test: arrival, IFR, on a virtual frequency, +// at the requested altitude with that altitude assigned. +func newTestAircraftAtAltitude(t *testing.T, altitude float32) *Aircraft { + t.Helper() + ac := &Aircraft{ + ADSBCallsign: "TEST123", + TypeOfFlight: av.FlightTypeArrival, + ControllerFrequency: "TEST_TCP", + } + ac.Nav.FlightState.Altitude = altitude + ac.Nav.FlightState.IAS = 250 + ac.Nav.FlightState.GS = 250 + // NmPerLongitude prevents updatePositionAndGS from producing NaN + // coordinates that corrupt nearestActualAltim lookups. ~45.5 is + // correct for 40-41° N (NY/NJ area). + ac.Nav.FlightState.NmPerLongitude = 45.5 + assigned := altitude + ac.Nav.Altitude.Assigned = &assigned + // Set realistic climb/descent rates so updateAltitude can actually move + // the aircraft when a bias shifts the target altitude. + ac.Nav.Perf.Rate.Climb = 2000 // ft/min + ac.Nav.Perf.Rate.Descent = 2000 // ft/min + ac.Nav.Perf.Speed.V2 = 150 // kts; ensures IsAirborne() returns true at IAS=250 + return ac +} + +// tickOnce advances the sim by one update cycle. Mirrors the per-aircraft +// loop body in sim.go in miniature. +func (s *Sim) tickOnce() { + s.State.SimTime = s.State.SimTime.Add(time.Second) + stubModel := &wx.Model{} + for _, ac := range s.Aircraft { + var bias float32 + if s.State.FacilityAdaptation.SimulatePilotAltimeter && + ac.Nav.IsAirborne() && ac.Altitude() < 18000 { + actual := s.nearestActualAltim(ac.Position()) + bias = altimBiasFeet(actual, ac.PilotAltim) + } + ac.Update(stubModel, bias, s.State.SimTime, nil, nil) + } +} + +// TestAltimeterBiasShiftsScopedAltitude verifies the full physics path: +// an aircraft set to one airport's altimeter, flown near a different +// airport with a different altimeter, accrues bias visible on the scope. +func TestAltimeterBiasShiftsScopedAltitude(t *testing.T) { + s := newTestSimWithMETAR(t, map[string]float32{ + "KJFK": 30.05, + "KABE": 29.95, // ~70 NM west, lower altimeter + }) + s.State.FacilityAdaptation.SimulatePilotAltimeter = true + // Initialize runtime-only fields that GenerateContactTransmission needs. + s.Rand = rand.Make() + s.eventStream = NewEventStream(nil) + + ac := newTestAircraftAtAltitude(t, 5000) + ac.Nav.FlightState.Position = math.Point2LL{-73.78, 40.64} // KJFK + ac.PilotAltim = 30.05 + ac.PilotAltimSetAt = s.State.SimTime + if s.Aircraft == nil { + s.Aircraft = make(map[av.ADSBCallsign]*Aircraft) + } + s.Aircraft[ac.ADSBCallsign] = ac + + // Tick once near KJFK — bias should be ~0. + s.tickOnce() + if d := math.Abs(ac.Nav.FlightState.Altitude - 5000); d > 1 { + t.Errorf("near KJFK: altitude = %v, want ~5000 (delta %v)", ac.Nav.FlightState.Altitude, d) + } + + // Teleport near KABE. + ac.Nav.FlightState.Position = math.Point2LL{-75.44, 40.65} // KABE + // Run several ticks to let the aircraft drift to the new biased target. + for i := 0; i < 60; i++ { + s.tickOnce() + } + // Expected bias: (29.95 - 30.05) * 1000 = -100 ft. + if d := math.Abs(ac.Nav.FlightState.Altitude - 4900); d > 50 { + t.Errorf("near KABE after settle: altitude = %v, want ~4900 (delta %v)", ac.Nav.FlightState.Altitude, d) + } + + // Issue an altimeter-setting command. This goes through the dispatcher + // and the readback render, which mutates PilotAltim. + s.handleAltimeterSetting(ac, 2995) + pc := s.popReadyContact([]TCP{TCP(ac.ControllerFrequency)}) + if pc == nil { + t.Fatal("expected a pending altimeter readback contact") + } + s.GenerateContactTransmission(pc) // triggers the render-switch state mutation + + if math.Abs(ac.PilotAltim-29.95) > 0.001 { + t.Errorf("after readback: PilotAltim = %v, want 29.95", ac.PilotAltim) + } + + // Tick to let the aircraft drift back. Bias is now 0 (29.95 == 29.95). + for i := 0; i < 60; i++ { + s.tickOnce() + } + if d := math.Abs(ac.Nav.FlightState.Altitude - 5000); d > 50 { + t.Errorf("after correction: altitude = %v, want ~5000 (delta %v)", ac.Nav.FlightState.Altitude, d) + } +} diff --git a/sim/altimeter_test.go b/sim/altimeter_test.go index f3632a256..320dea13a 100644 --- a/sim/altimeter_test.go +++ b/sim/altimeter_test.go @@ -18,6 +18,7 @@ var knownTestAirportLocations = map[string]math.Point2LL{ "KJFK": {-73.78, 40.64}, "KLGA": {-73.87, 40.77}, "KEWR": {-74.17, 40.69}, + "KABE": {-75.44, 40.65}, } func newTestSimWithMETAR(t *testing.T, settings map[string]float32) *Sim { From f3610a01820bd543446ffc1ff6a155092ab31169 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:27:45 -0400 Subject: [PATCH 11/27] stt: extract altimeter setting mid-sentence and dispatch when sole content - Scan whole token stream for altimeter keyword (not just suffix), splice matched tokens out so the rest of the transmission still parses. - Fuzzy-match the altimeter keyword (Jaro-Winkler 0.85) so "altometer", "altimeters", etc. still trigger extraction. - Accept "N point NN" form alongside "NNNN" and "N NN". - Dispatch ALT/ when altimeter is the only content in the transmission, instead of returning empty. - Update test JSON expected commands to include the now-extracted ALT tokens. --- stt/provider.go | 106 ++++++++++++------ stt/provider_test.go | 41 +++++-- ...wenty_one_boston_approach_informati_1.json | 2 +- ...hirty_two_naked_departure_information.json | 2 +- ...hree_hello_kennedy_altimeter_two_nine.json | 2 +- ...ve_forty_six_negative_descend_via_the.json | 2 +- ...wenty_seven_newark_approach_informati.json | 2 +- 7 files changed, 106 insertions(+), 51 deletions(-) diff --git a/stt/provider.go b/stt/provider.go index 3f818f296..771969c74 100644 --- a/stt/provider.go +++ b/stt/provider.go @@ -173,7 +173,16 @@ func (p *Transcriber) decodeInternal( // If no tokens remain after stripping, controller just identified themselves. // For VFR aircraft, treat this as an implicit "go ahead" — the pilot is // checking in on frequency with just their callsign + facility name. + // Altimeter-only transmissions dispatch just the ALT synthetic command. if len(commandTokens) == 0 { + if altimOK { + output := callsign + " " + fmt.Sprintf("ALT/%d", altimSetting) + elapsed := time.Since(start) + logLocalStt("altimeter-only transmission, dispatching %s", output) + logLocalStt(`=== DecodeTranscript END: %q (altimeter only, time=%s) ===`, output, elapsed) + p.logInfo(`local STT: %q -> %q (altimeter only, time=%s)`, transcript, output, elapsed) + return output, nil + } if ac.State == "vfr flight following" { output := callsign + " GA" elapsed := time.Since(start) @@ -1042,15 +1051,15 @@ func containsGreeting(tokens []Token) bool { } // stripInformational applies all informational prefix/suffix strippers in sequence: -// position ID prefix, radar contact prefix, and altimeter setting suffix. +// position ID prefix, radar contact prefix, and altimeter setting. // Returns the stripped tokens plus the altimeter setting in hundredths of inHg -// (e.g. 3002 for 30.02) and ok=true if an altimeter suffix was found. +// (e.g. 3002 for 30.02) and ok=true if an altimeter setting was found. func stripInformational(tokens []Token) ([]Token, int, bool) { tokens = stripPositionIDPrefix(tokens) tokens = stripRadarContactPrefix(tokens) var altimSetting int var altimOK bool - tokens, altimSetting, altimOK = extractAltimeterSuffix(tokens) + tokens, altimSetting, altimOK = extractAltimeterSetting(tokens) return tokens, altimSetting, altimOK } @@ -1108,57 +1117,80 @@ func stripRadarContactPrefix(tokens []Token) []Token { return tokens } -// extractAltimeterSuffix removes an altimeter setting from the end of the -// token stream and returns the parsed value (hundredths of inHg, e.g., 3002 -// for 30.02). Returns ok=false if no altimeter setting is found at the end. +// extractAltimeterSetting finds an altimeter setting anywhere in the token +// stream (not just at the end), splices out the matched tokens, and returns +// the parsed value in hundredths of inHg (e.g., 3002 for 30.02). Returns +// ok=false when no altimeter setting is found. // -// Recognized forms (after the optional "altimeter" keyword): +// Recognized forms after the "altimeter" keyword (fuzzy-matched to tolerate +// STT mis-transcriptions like "altometer", "altimeters"): // - one number token: "3002" → 3002 -// - two number tokens: "30 02" → 3002 (or "29 95" → 2995) -// - spelled-out digits parsed by the upstream tokenizer. -func extractAltimeterSuffix(tokens []Token) ([]Token, int, bool) { +// - two number tokens: "30 02" → 3002 +// - "point" form: "30 point 02" → 3002 +// +// If a single bare word (airport/station name) immediately precedes +// "altimeter" and is not a command keyword, it is spliced out along with +// the altimeter phrase so it doesn't confuse downstream parsing. +func extractAltimeterSetting(tokens []Token) ([]Token, int, bool) { for i, t := range tokens { - if strings.ToLower(t.Text) != "altimeter" { - continue - } - if i+1 >= len(tokens) || tokens[i+1].Type != TokenNumber { + if !isAltimeterKeyword(t.Text) { continue } - - // Peek ahead: must be the trailing region of the transmission. - // Either one number token at the very end, or two number tokens at the end. - var settingTokens []Token - switch len(tokens) - i { - case 2: // "altimeter 3002" - settingTokens = tokens[i+1 : i+2] - case 3: // "altimeter 30 02" - if tokens[i+2].Type != TokenNumber { - continue - } - settingTokens = tokens[i+1 : i+3] - default: - continue - } - - hundredths, ok := parseAltimeterTokens(settingTokens) + hundredths, consumed, ok := parseAltimeterForms(tokens[i+1:]) if !ok { continue } - - // Trim the optional "(airport)" prefix word if present, to mirror - // the legacy stripAltimeterSuffix behavior. start := i if start > 0 && tokens[start-1].Type == TokenWord && !IsCommandKeyword(strings.ToLower(tokens[start-1].Text)) { start-- } - logLocalStt("extracted altimeter suffix: %d hundredths, %d tokens consumed", - hundredths, len(tokens)-start) - return tokens[:start], hundredths, true + end := i + 1 + consumed + logLocalStt("extracted altimeter setting: %d hundredths, tokens[%d:%d]", + hundredths, start, end) + remaining := make([]Token, 0, len(tokens)-(end-start)) + remaining = append(remaining, tokens[:start]...) + remaining = append(remaining, tokens[end:]...) + return remaining, hundredths, true } return tokens, 0, false } +// isAltimeterKeyword reports whether text is a reasonable transcription of +// "altimeter". Accepts the canonical spelling and common whisper +// mis-transcriptions via Jaro-Winkler fuzzy matching. +func isAltimeterKeyword(text string) bool { + if strings.EqualFold(text, "altimeter") { + return true + } + return FuzzyMatch(text, "altimeter", 0.85) +} + +// parseAltimeterForms parses the token sequence immediately after "altimeter" +// and returns the setting in hundredths of inHg plus the number of tokens +// consumed. Tries three forms in order: single four-digit (3002), +// "N point NN" (30 point 02), and two-token "N NN" (30 02). +func parseAltimeterForms(after []Token) (int, int, bool) { + if len(after) == 0 || after[0].Type != TokenNumber { + return 0, 0, false + } + if h, ok := parseAltimeterTokens(after[:1]); ok { + return h, 1, true + } + if len(after) >= 3 && strings.EqualFold(after[1].Text, "point") && + after[2].Type == TokenNumber { + if h, ok := parseAltimeterTokens([]Token{after[0], after[2]}); ok { + return h, 3, true + } + } + if len(after) >= 2 && after[1].Type == TokenNumber { + if h, ok := parseAltimeterTokens(after[:2]); ok { + return h, 2, true + } + } + return 0, 0, false +} + // parseAltimeterTokens converts one or two number tokens to hundredths-of-inHg. // "3002" → 3002; "30" + "02" → 3002. func parseAltimeterTokens(toks []Token) (int, bool) { diff --git a/stt/provider_test.go b/stt/provider_test.go index f00b11cae..5cbdbcba3 100644 --- a/stt/provider_test.go +++ b/stt/provider_test.go @@ -1978,31 +1978,54 @@ func TestTokenize(t *testing.T) { } } -func TestExtractAltimeterSuffix(t *testing.T) { +func TestExtractAltimeterSetting(t *testing.T) { tests := []struct { name string input string wantHundredths int wantOK bool + wantRemaining string // expected TokensToString of remaining tokens }{ - {"altimeter four-digit", "altimeter 3002", 3002, true}, - {"altimeter spaced", "altimeter 30 02", 3002, true}, - {"altimeter spelled-out", "altimeter three zero zero two", 3002, true}, - {"altimeter spoken thirty oh two", "altimeter thirty oh two", 3002, true}, - {"no altimeter", "turn left heading 270", 0, false}, - {"altimeter alone", "altimeter", 0, false}, - {"junk after altimeter number", "altimeter 3002 climb", 0, false}, + {"altimeter four-digit", "altimeter 3002", 3002, true, ""}, + {"altimeter spaced", "altimeter 30 02", 3002, true, ""}, + {"altimeter spelled-out", "altimeter three zero zero two", 3002, true, ""}, + {"altimeter spoken thirty oh two", "altimeter thirty oh two", 3002, true, ""}, + {"no altimeter", "turn left heading 270", 0, false, "turn left heading 270"}, + {"altimeter alone", "altimeter", 0, false, "altimeter"}, + + // Mid-sentence extraction: trailing tokens survive. + {"altimeter before command", "altimeter 3002 climb", 3002, true, "climb"}, + {"altimeter mid-sentence with station", "kennedy altimeter 3002 expect ils", + 3002, true, "expect ils"}, + {"altimeter after descent", "descend and maintain 5000 altimeter 3002", + 3002, true, "descend and maintain 5000"}, + + // "point" form. + {"altimeter point form", "altimeter 30 point 02", 3002, true, ""}, + {"altimeter point form mid-sentence", "altimeter 30 point 14 climb", + 3014, true, "climb"}, + + // Fuzzy match for whisper mis-transcriptions. + {"altimeters (plural)", "altimeters 3002", 3002, true, ""}, + {"altometer (missing i)", "altometer 3002", 3002, true, ""}, + + // Out-of-range values are rejected. + {"out-of-range low", "altimeter 2400", 0, false, "altimeter 2400"}, + {"out-of-range high", "altimeter 3500", 0, false, "altimeter 3500"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tokens := tokenize(tc.input) - _, hundredths, ok := extractAltimeterSuffix(tokens) + remaining, hundredths, ok := extractAltimeterSetting(tokens) if ok != tc.wantOK { t.Fatalf("ok = %v, want %v", ok, tc.wantOK) } if ok && hundredths != tc.wantHundredths { t.Errorf("hundredths = %d, want %d", hundredths, tc.wantHundredths) } + if got := TokensToString(remaining); got != tc.wantRemaining { + t.Errorf("remaining = %q, want %q", got, tc.wantRemaining) + } }) } } diff --git a/stt/tests/american_four_twenty_one_boston_approach_informati_1.json b/stt/tests/american_four_twenty_one_boston_approach_informati_1.json index c09e63300..b1d249b6f 100644 --- a/stt/tests/american_four_twenty_one_boston_approach_informati_1.json +++ b/stt/tests/american_four_twenty_one_boston_approach_informati_1.json @@ -15,7 +15,7 @@ "processor": "GPU: NVIDIA GeForce RTX 5060 Ti (16050MB)", "whisper_model": "ggml-medium.en-jlvatc-q5_0.bin", "callsign": "AAL421", - "command": "ATIS/A EI4R", + "command": "ATIS/A EI4R ALT/2919", "stt_aircraft": { "American four 21": { "Callsign": "AAL421", diff --git a/stt/tests/jetblue_one_thirty_two_naked_departure_information.json b/stt/tests/jetblue_one_thirty_two_naked_departure_information.json index be7ace4c6..abd3b8dca 100644 --- a/stt/tests/jetblue_one_thirty_two_naked_departure_information.json +++ b/stt/tests/jetblue_one_thirty_two_naked_departure_information.json @@ -15,7 +15,7 @@ "processor": "GPU: NVIDIA GeForce RTX 5060 Ti (16050MB)", "whisper_model": "ggml-medium.en-jlvatc-q5_0.bin", "callsign": "JBU132", - "command": "ATIS/A EI2L", + "command": "ATIS/A EI2L ALT/3022", "stt_aircraft": { "JetBlue one 32": { "Callsign": "JBU132", diff --git a/stt/tests/oscar_forty_three_hello_kennedy_altimeter_two_nine.json b/stt/tests/oscar_forty_three_hello_kennedy_altimeter_two_nine.json index 16948a2f4..02cdd6026 100644 --- a/stt/tests/oscar_forty_three_hello_kennedy_altimeter_two_nine.json +++ b/stt/tests/oscar_forty_three_hello_kennedy_altimeter_two_nine.json @@ -15,7 +15,7 @@ "processor": "GPU: NVIDIA GeForce RTX 3060 (12329MB)", "whisper_model": "ggml-medium.en-jlvatc-q5_0.bin", "callsign": "ASA43", - "command": "EI2L", + "command": "EI2L ALT/2978", "stt_aircraft": { "Alaska 43": { "Callsign": "ASA43", diff --git a/stt/tests/south_west_five_forty_six_negative_descend_via_the.json b/stt/tests/south_west_five_forty_six_negative_descend_via_the.json index cfff5ff14..777b572ef 100644 --- a/stt/tests/south_west_five_forty_six_negative_descend_via_the.json +++ b/stt/tests/south_west_five_forty_six_negative_descend_via_the.json @@ -15,7 +15,7 @@ "processor": "GPU: NVIDIA GeForce GTX 1660 Ti with Max-Q Design (6180MB)", "whisper_model": "ggml-small.en-jlvatc-q5_0.bin", "callsign": "SWA546", - "command": "DVS EI6", + "command": "DVS EI6 ALT/3014", "stt_aircraft": { "Southwest five 46": { "Callsign": "SWA546", diff --git a/stt/tests/united_nine_twenty_seven_newark_approach_informati.json b/stt/tests/united_nine_twenty_seven_newark_approach_informati.json index 03bc502b8..e622d95b2 100644 --- a/stt/tests/united_nine_twenty_seven_newark_approach_informati.json +++ b/stt/tests/united_nine_twenty_seven_newark_approach_informati.json @@ -15,7 +15,7 @@ "processor": "GPU: NVIDIA GeForce RTX 5060 Ti (16050MB)", "whisper_model": "ggml-medium.en-jlvatc-q5_0.bin", "callsign": "UAL927", - "command": "ATIS/A EI4R", + "command": "ATIS/A EI4R ALT/3010", "stt_aircraft": { "Brickyard 47 56": { "Callsign": "RPA4756", From f090a92075c592e00d0f6b3c7c46a6e788c336b3 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:28:21 -0400 Subject: [PATCH 12/27] N90: enable simulate_pilot_altimeter Turn the toggle on for the N90 facility so the altimeter setting feature gets exercised in the default scenario. --- resources/configurations/ZNY/N90.json | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/configurations/ZNY/N90.json b/resources/configurations/ZNY/N90.json index 7bae2bbff..183c9722a 100644 --- a/resources/configurations/ZNY/N90.json +++ b/resources/configurations/ZNY/N90.json @@ -714,6 +714,7 @@ }, "center": "41.0669531,-73.7075661", "range": 60, + "simulate_pilot_altimeter": true, "significant_points": { "ARD": {}, "BAYYS": {}, From d4d91961c01c1ca0c97f41dfee39d8b32a31e8d1 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:44:58 -0400 Subject: [PATCH 13/27] sim+nav: report indicated altitude in SayAltitude The pilot reads off their altimeter, which is offset from true altitude when the pilot's setting differs from the local actual. Subtract the bias so the verbal "say altitude" response matches what the pilot actually sees, even when the scope shows the (corrected) true altitude. Factor altimBiasFor() out of the per-tick loop so SayAltitude and the update loop compute the bias the same way. --- nav/alt_test.go | 2 +- nav/commands.go | 13 ++++++++----- sim/aircraft.go | 4 ++-- sim/altimeter.go | 14 ++++++++++++++ sim/altimeter_integration_test.go | 8 +------- sim/commands.go | 2 +- sim/sim.go | 8 +------- 7 files changed, 28 insertions(+), 23 deletions(-) diff --git a/nav/alt_test.go b/nav/alt_test.go index 0bea57b1b..09e3ca2ee 100644 --- a/nav/alt_test.go +++ b/nav/alt_test.go @@ -121,7 +121,7 @@ func TestSayAltitudeReportsPendingAssignedAltitude(t *testing.T) { }) f.AssignAltitude(3000) - intent, ok := f.nav.SayAltitude().(av.ReportAltitudeIntent) + intent, ok := f.nav.SayAltitude(0).(av.ReportAltitudeIntent) if !ok { t.Fatalf("expected ReportAltitudeIntent, got %T", intent) } diff --git a/nav/commands.go b/nav/commands.go index c7d688854..cc6ec2cb3 100644 --- a/nav/commands.go +++ b/nav/commands.go @@ -346,14 +346,17 @@ func (nav *Nav) SayHeading() av.CommandIntent { return intent } -func (nav *Nav) SayAltitude() av.CommandIntent { - currentAltitude := nav.FlightState.Altitude - intent := av.ReportAltitudeIntent{Current: currentAltitude} +// SayAltitude reports the pilot's *indicated* altitude. The pilot reads +// off their altimeter, which is offset from true altitude by altimBiasFeet +// when the pilot's setting differs from the local actual. +func (nav *Nav) SayAltitude(altimBiasFeet float32) av.CommandIntent { + indicatedAltitude := nav.FlightState.Altitude - altimBiasFeet + intent := av.ReportAltitudeIntent{Current: indicatedAltitude} if nav.Altitude.Assigned != nil { intent.Assigned = nav.Altitude.Assigned - if *nav.Altitude.Assigned < currentAltitude { + if *nav.Altitude.Assigned < indicatedAltitude { intent.Direction = av.AltitudeDescend - } else if *nav.Altitude.Assigned > currentAltitude { + } else if *nav.Altitude.Assigned > indicatedAltitude { intent.Direction = av.AltitudeClimb } else { intent.Direction = av.AltitudeMaintain diff --git a/sim/aircraft.go b/sim/aircraft.go index e2f11bf8c..f77e691a1 100644 --- a/sim/aircraft.go +++ b/sim/aircraft.go @@ -361,8 +361,8 @@ func (ac *Aircraft) SayHeading() av.CommandIntent { return ac.Nav.SayHeading() } -func (ac *Aircraft) SayAltitude() av.CommandIntent { - return ac.Nav.SayAltitude() +func (ac *Aircraft) SayAltitude(altimBiasFeet float32) av.CommandIntent { + return ac.Nav.SayAltitude(altimBiasFeet) } func (ac *Aircraft) ExpediteDescent() av.CommandIntent { diff --git a/sim/altimeter.go b/sim/altimeter.go index b2e90256d..0e41f10a3 100644 --- a/sim/altimeter.go +++ b/sim/altimeter.go @@ -101,6 +101,20 @@ func altimBiasFeet(nearestActualInHg, pilotInHg float32) float32 { return (nearestActualInHg - pilotInHg) * 1000 } +// altimBiasFor returns the current altimeter bias for ac, applying the same +// gating as the per-tick update loop (feature on, airborne, below FL180). +// Returns 0 when the bias should not apply. +func (s *Sim) altimBiasFor(ac *Aircraft) float32 { + if !s.State.FacilityAdaptation.SimulatePilotAltimeter { + return 0 + } + if !ac.Nav.IsAirborne() || ac.Altitude() >= 18000 { + return 0 + } + actual := s.nearestActualAltim(ac.Position()) + return altimBiasFeet(actual, ac.PilotAltim) +} + // nearestActualAltim returns the altimeter (inHg) at the METAR-reporting // station geographically closest to pos. Returns 0 if no usable METAR is // available; callers treat 0 as "skip bias entirely". diff --git a/sim/altimeter_integration_test.go b/sim/altimeter_integration_test.go index c988faf1b..327e14681 100644 --- a/sim/altimeter_integration_test.go +++ b/sim/altimeter_integration_test.go @@ -47,13 +47,7 @@ func (s *Sim) tickOnce() { s.State.SimTime = s.State.SimTime.Add(time.Second) stubModel := &wx.Model{} for _, ac := range s.Aircraft { - var bias float32 - if s.State.FacilityAdaptation.SimulatePilotAltimeter && - ac.Nav.IsAirborne() && ac.Altitude() < 18000 { - actual := s.nearestActualAltim(ac.Position()) - bias = altimBiasFeet(actual, ac.PilotAltim) - } - ac.Update(stubModel, bias, s.State.SimTime, nil, nil) + ac.Update(stubModel, s.altimBiasFor(ac), s.State.SimTime, nil, nil) } } diff --git a/sim/commands.go b/sim/commands.go index c60ac859f..1f4caafc6 100644 --- a/sim/commands.go +++ b/sim/commands.go @@ -162,7 +162,7 @@ func (s *Sim) SayAltitude(tcw TCW, callsign av.ADSBCallsign) (av.CommandIntent, return s.dispatchControlledAircraftCommand(tcw, callsign, func(tcw TCW, ac *Aircraft) av.CommandIntent { - return ac.SayAltitude() + return ac.SayAltitude(s.altimBiasFor(ac)) }) } diff --git a/sim/sim.go b/sim/sim.go index fc3437381..d4fdacc23 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -968,13 +968,7 @@ func (s *Sim) updateState() { continue } - var bias float32 - if s.State.FacilityAdaptation.SimulatePilotAltimeter && - ac.Nav.IsAirborne() && - ac.Altitude() < 18000 { - actual := s.nearestActualAltim(ac.Position()) - bias = altimBiasFeet(actual, ac.PilotAltim) - } + bias := s.altimBiasFor(ac) updateResult := ac.Update(s.wxModel, bias, s.State.SimTime, s.bravoAirspace, nil /* s.lg*/) passedWaypoint := updateResult.PassedWaypoint s.refreshSeenTraffic(ac) From 37947d255494b8a5db66662872763f357abb7fc7 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:52:19 -0400 Subject: [PATCH 14/27] sim+aviation: integrate altimeter readback into bulk transmissions Altimeter setting was dispatched as a separate PendingContact, so when combined with other commands ("descend 5000, altimeter 30.05") the pilot read it back as a second transmission. Return it as a CommandIntent like AssignHeading / AssignAltitude so it merges into the single readback string. Also fix the spoken form to read the setting as four individual digits ("altimeter three zero zero five") instead of two grouped numbers ("altimeter thirty five"). --- aviation/intent.go | 7 +++---- sim/altimeter_integration_test.go | 15 +++++++------- sim/command_parser.go | 2 +- sim/radio.go | 33 ++++++++----------------------- 4 files changed, 20 insertions(+), 37 deletions(-) diff --git a/aviation/intent.go b/aviation/intent.go index bfc53bd87..35c362549 100644 --- a/aviation/intent.go +++ b/aviation/intent.go @@ -1022,15 +1022,14 @@ func (m MixUpIntent) Render(rt *RadioTransmission, r *rand.Rand) { // AltimeterReadback Intent // AltimeterReadbackIntent renders a pilot's readback of an altimeter setting -// issued by the controller, e.g., "altimeter three zero zero two, American 123". +// issued by the controller, e.g., "altimeter three zero zero two". type AltimeterReadbackIntent struct { SettingHundredths int // e.g., 3002 for 30.02 } func (a AltimeterReadbackIntent) Render(rt *RadioTransmission, r *rand.Rand) { - whole := a.SettingHundredths / 100 - hundredths := a.SettingHundredths % 100 - rt.Add("[{num} {num}|altimeter {num} {num}|roger {num} {num}]", whole, hundredths) + digits := sayDigits(a.SettingHundredths, 4) + rt.Add("[altimeter " + digits + "|" + digits + "|roger " + digits + "]") } /////////////////////////////////////////////////////////////////////////// diff --git a/sim/altimeter_integration_test.go b/sim/altimeter_integration_test.go index 327e14681..717019f2e 100644 --- a/sim/altimeter_integration_test.go +++ b/sim/altimeter_integration_test.go @@ -90,14 +90,15 @@ func TestAltimeterBiasShiftsScopedAltitude(t *testing.T) { t.Errorf("near KABE after settle: altitude = %v, want ~4900 (delta %v)", ac.Nav.FlightState.Altitude, d) } - // Issue an altimeter-setting command. This goes through the dispatcher - // and the readback render, which mutates PilotAltim. - s.handleAltimeterSetting(ac, 2995) - pc := s.popReadyContact([]TCP{TCP(ac.ControllerFrequency)}) - if pc == nil { - t.Fatal("expected a pending altimeter readback contact") + // Issue an altimeter-setting command. The dispatcher mutates PilotAltim + // immediately and returns a readback intent. + intent := s.handleAltimeterSetting(ac, 2995) + if intent == nil { + t.Fatal("expected an AltimeterReadbackIntent, got nil") + } + if _, ok := intent.(av.AltimeterReadbackIntent); !ok { + t.Fatalf("expected AltimeterReadbackIntent, got %T", intent) } - s.GenerateContactTransmission(pc) // triggers the render-switch state mutation if math.Abs(ac.PilotAltim-29.95) > 0.001 { t.Errorf("after readback: PilotAltim = %v, want 29.95", ac.PilotAltim) diff --git a/sim/command_parser.go b/sim/command_parser.go index 57ac81b46..b7f3b9d94 100644 --- a/sim/command_parser.go +++ b/sim/command_parser.go @@ -465,7 +465,7 @@ func (s *Sim) runOneControlCommand(tcw TCW, callsign av.ADSBCallsign, command st return nil, nil // silently ignore malformed } if ac, ok := s.Aircraft[callsign]; ok { - s.handleAltimeterSetting(ac, setting) + return s.handleAltimeterSetting(ac, setting), nil } return nil, nil } else { diff --git a/sim/radio.go b/sim/radio.go index 951a8b4bf..7533e36ac 100644 --- a/sim/radio.go +++ b/sim/radio.go @@ -55,7 +55,6 @@ const ( PendingTransmissionRequestVisual // Spontaneous "field in sight, requesting visual" PendingTransmissionRequestVectors // Pilot requesting vectors (overshot localizer) PendingTransmissionRequestAltitude // Pilot requesting altitude after being vectored off STAR - PendingTransmissionAltimeterReadback // After controller issues "altimeter X.XX" ) // FutureFrequencyChange represents a pilot switching to a new frequency. @@ -77,7 +76,6 @@ type PendingContact struct { HasQueuedEmergency bool // For departures: trigger emergency after contact PrebuiltTransmission *av.RadioTransmission // For emergency transmissions: pre-built message FirstInFacility bool // For arrivals: first contact in this TRACON facility - AltimeterHundredths int // For PendingTransmissionAltimeterReadback: e.g., 3002 for 30.02 } // hasPendingCheckIn reports whether the aircraft has a pending arrival or @@ -332,22 +330,16 @@ func (s *Sim) enqueueEmergencyTransmission(callsign av.ADSBCallsign, tcp TCP, rt } // handleAltimeterSetting processes an "altimeter X.XX" command issued by a -// controller. If the feature toggle is off, silently accepts the command and -// no-ops. Otherwise enqueues a pilot readback that will mutate PilotAltim -// when rendered. -func (s *Sim) handleAltimeterSetting(ac *Aircraft, settingHundredths int) { +// controller. Mutates the pilot's altimeter setting and returns a readback +// intent so the acknowledgment joins any other readbacks from the same +// transmission. Returns nil when the feature toggle is off. +func (s *Sim) handleAltimeterSetting(ac *Aircraft, settingHundredths int) av.CommandIntent { if !s.State.FacilityAdaptation.SimulatePilotAltimeter { - return - } - if ac.ControllerFrequency == "" { - return + return nil } - s.addPendingContact(PendingContact{ - ADSBCallsign: ac.ADSBCallsign, - TCP: TCP(ac.ControllerFrequency), - Type: PendingTransmissionAltimeterReadback, - AltimeterHundredths: settingHundredths, - }) + ac.PilotAltim = float32(settingHundredths) / 100 + ac.PilotAltimSetAt = s.State.SimTime + return av.AltimeterReadbackIntent{SettingHundredths: settingHundredths} } // cancelPendingInitialContact removes any pending Departure or Arrival contact @@ -441,15 +433,6 @@ func (s *Sim) GenerateContactTransmission(pc *PendingContact) (spokenText, writt s.runEmergencyStage(ac) } - case PendingTransmissionAltimeterReadback: - setting := pc.AltimeterHundredths - ac.PilotAltim = float32(setting) / 100 - ac.PilotAltimSetAt = s.State.SimTime - whole := setting / 100 - hundredths := setting % 100 - rt = av.MakeContactTransmission("[{num} {num}|altimeter {num} {num}|roger {num} {num}]", - whole, hundredths) - case PendingTransmissionTrafficInSight: rt = av.MakeContactTransmission("[we've got the traffic|we have the traffic in sight|traffic in sight now]") From a9f47865356c82e77441f4131a061f6d42a14d0d Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:59:56 -0400 Subject: [PATCH 15/27] sim: tune pilot altimeter when controller issues ATIS letter Telling an aircraft "information alpha" implies the pilot just listened to that ATIS and now has the current altimeter. Set PilotAltim to the ATIS airport's METAR so subsequent altitude assignments use the correct bias. --- sim/altimeter.go | 24 ++++++++++++++++++++++++ sim/commands.go | 5 ++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/sim/altimeter.go b/sim/altimeter.go index 0e41f10a3..c4dbd6f37 100644 --- a/sim/altimeter.go +++ b/sim/altimeter.go @@ -101,6 +101,30 @@ func altimBiasFeet(nearestActualInHg, pilotInHg float32) float32 { return (nearestActualInHg - pilotInHg) * 1000 } +// tunePilotAltimToATISAirport sets the pilot's altimeter to the METAR for +// the airport whose ATIS the pilot just acknowledged. Arrivals prefer the +// arrival airport; everything else prefers the departure airport. No-op +// when the feature is off or no METAR is available. +func (s *Sim) tunePilotAltimToATISAirport(ac *Aircraft) { + if !s.State.FacilityAdaptation.SimulatePilotAltimeter { + return + } + candidates := []string{ac.FlightPlan.ArrivalAirport, ac.FlightPlan.DepartureAirport} + if ac.TypeOfFlight != av.FlightTypeArrival { + candidates = []string{ac.FlightPlan.DepartureAirport, ac.FlightPlan.ArrivalAirport} + } + for _, icao := range candidates { + if icao == "" { + continue + } + if m, ok := s.State.METAR[icao]; ok { + ac.PilotAltim = m.Altimeter_inHg() + ac.PilotAltimSetAt = s.State.SimTime + return + } + } +} + // altimBiasFor returns the current altimeter bias for ac, applying the same // gating as the per-tick update loop (feature on, airborne, below FL180). // Returns 0 when the bias should not apply. diff --git a/sim/commands.go b/sim/commands.go index 1f4caafc6..9829f19f6 100644 --- a/sim/commands.go +++ b/sim/commands.go @@ -405,7 +405,9 @@ func (s *Sim) ContactTower(tcw TCW, callsign av.ADSBCallsign, freq av.Frequency) // ATISCommand handles the controller telling a pilot the current ATIS letter. // If the aircraft already reported the correct ATIS, no readback is needed. -// Otherwise the pilot responds with "we'll pick up (letter)". +// Otherwise the pilot responds with "we'll pick up (letter)" and (when the +// altimeter-sim feature is on) tunes their altimeter to the corresponding +// airport's current METAR. func (s *Sim) ATISCommand(tcw TCW, callsign av.ADSBCallsign, letter string) (av.CommandIntent, error) { s.mu.Lock(s.lg) defer s.mu.Unlock(s.lg) @@ -416,6 +418,7 @@ func (s *Sim) ATISCommand(tcw TCW, callsign av.ADSBCallsign, letter string) (av. return nil } ac.ReportedATIS = letter + s.tunePilotAltimToATISAirport(ac) return av.ATISIntent{Letter: letter} }) } From edcdf2c03fa6ddebd01fb70ecf8b748a2a7c3629 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:08:14 -0400 Subject: [PATCH 16/27] altim: enable pilot altimeter simulation by default Remove the SimulatePilotAltimeter FacilityAdaptation toggle so the feature runs unconditionally in all scenarios. Co-Authored-By: Claude Opus 4.7 --- resources/configurations/ZNY/N90.json | 1 - sim/altimeter.go | 18 ++++-------------- sim/altimeter_integration_test.go | 1 - sim/altimeter_test.go | 17 ----------------- sim/radio.go | 5 +---- sim/stars.go | 1 - 6 files changed, 5 insertions(+), 38 deletions(-) diff --git a/resources/configurations/ZNY/N90.json b/resources/configurations/ZNY/N90.json index 183c9722a..7bae2bbff 100644 --- a/resources/configurations/ZNY/N90.json +++ b/resources/configurations/ZNY/N90.json @@ -714,7 +714,6 @@ }, "center": "41.0669531,-73.7075661", "range": 60, - "simulate_pilot_altimeter": true, "significant_points": { "ARD": {}, "BAYYS": {}, diff --git a/sim/altimeter.go b/sim/altimeter.go index c4dbd6f37..e8ca56965 100644 --- a/sim/altimeter.go +++ b/sim/altimeter.go @@ -10,17 +10,13 @@ import ( ) // initPilotAltim sets ac.PilotAltim and ac.PilotAltimSetAt according to the -// hybrid spawn rule. No-op if SimulatePilotAltimeter is off. +// hybrid spawn rule. // // Categories: // - Departure: local field altimeter (or nearest METAR if airport has no METAR) // - Arrival / IFR overflight / VFR with flight-following: nearest METAR at spawn // - VFR overflight without flight-following: 70% nearest, 30% random within 100 NM func (s *Sim) initPilotAltim(ac *Aircraft) { - if !s.State.FacilityAdaptation.SimulatePilotAltimeter { - return - } - pos := ac.Nav.FlightState.Position wrongEligible := ac.TypeOfFlight == av.FlightTypeOverflight && @@ -104,11 +100,8 @@ func altimBiasFeet(nearestActualInHg, pilotInHg float32) float32 { // tunePilotAltimToATISAirport sets the pilot's altimeter to the METAR for // the airport whose ATIS the pilot just acknowledged. Arrivals prefer the // arrival airport; everything else prefers the departure airport. No-op -// when the feature is off or no METAR is available. +// when no METAR is available. func (s *Sim) tunePilotAltimToATISAirport(ac *Aircraft) { - if !s.State.FacilityAdaptation.SimulatePilotAltimeter { - return - } candidates := []string{ac.FlightPlan.ArrivalAirport, ac.FlightPlan.DepartureAirport} if ac.TypeOfFlight != av.FlightTypeArrival { candidates = []string{ac.FlightPlan.DepartureAirport, ac.FlightPlan.ArrivalAirport} @@ -126,12 +119,9 @@ func (s *Sim) tunePilotAltimToATISAirport(ac *Aircraft) { } // altimBiasFor returns the current altimeter bias for ac, applying the same -// gating as the per-tick update loop (feature on, airborne, below FL180). -// Returns 0 when the bias should not apply. +// gating as the per-tick update loop (airborne, below FL180). Returns 0 +// when the bias should not apply. func (s *Sim) altimBiasFor(ac *Aircraft) float32 { - if !s.State.FacilityAdaptation.SimulatePilotAltimeter { - return 0 - } if !ac.Nav.IsAirborne() || ac.Altitude() >= 18000 { return 0 } diff --git a/sim/altimeter_integration_test.go b/sim/altimeter_integration_test.go index 717019f2e..324f5010a 100644 --- a/sim/altimeter_integration_test.go +++ b/sim/altimeter_integration_test.go @@ -59,7 +59,6 @@ func TestAltimeterBiasShiftsScopedAltitude(t *testing.T) { "KJFK": 30.05, "KABE": 29.95, // ~70 NM west, lower altimeter }) - s.State.FacilityAdaptation.SimulatePilotAltimeter = true // Initialize runtime-only fields that GenerateContactTransmission needs. s.Rand = rand.Make() s.eventStream = NewEventStream(nil) diff --git a/sim/altimeter_test.go b/sim/altimeter_test.go index 320dea13a..97f4907c2 100644 --- a/sim/altimeter_test.go +++ b/sim/altimeter_test.go @@ -86,25 +86,8 @@ func TestNearestActualAltimEmptyMap(t *testing.T) { } } -func TestInitPilotAltimDisabledIsNoop(t *testing.T) { - s := &Sim{ - State: &CommonState{ - DynamicState: DynamicState{ - METAR: map[string]wx.METAR{"KJFK": {Altimeter: 30.05 / 0.02953}}, - }, - FacilityAdaptation: FacilityAdaptation{SimulatePilotAltimeter: false}, - }, - } - ac := &Aircraft{} - s.initPilotAltim(ac) - if ac.PilotAltim != 0 { - t.Errorf("toggle off: PilotAltim = %v, want 0", ac.PilotAltim) - } -} - func TestInitPilotAltimSetsCorrectForArrival(t *testing.T) { s := newTestSimWithMETAR(t, map[string]float32{"KJFK": 30.05}) - s.State.FacilityAdaptation.SimulatePilotAltimeter = true ac := &Aircraft{TypeOfFlight: av.FlightTypeArrival} ac.Nav.FlightState.Position = math.Point2LL{-73.78, 40.64} // near KJFK s.initPilotAltim(ac) diff --git a/sim/radio.go b/sim/radio.go index 7533e36ac..4b3e2c923 100644 --- a/sim/radio.go +++ b/sim/radio.go @@ -332,11 +332,8 @@ func (s *Sim) enqueueEmergencyTransmission(callsign av.ADSBCallsign, tcp TCP, rt // handleAltimeterSetting processes an "altimeter X.XX" command issued by a // controller. Mutates the pilot's altimeter setting and returns a readback // intent so the acknowledgment joins any other readbacks from the same -// transmission. Returns nil when the feature toggle is off. +// transmission. func (s *Sim) handleAltimeterSetting(ac *Aircraft, settingHundredths int) av.CommandIntent { - if !s.State.FacilityAdaptation.SimulatePilotAltimeter { - return nil - } ac.PilotAltim = float32(settingHundredths) / 100 ac.PilotAltimSetAt = s.State.SimTime return av.AltimeterReadbackIntent{SettingHundredths: settingHundredths} diff --git a/sim/stars.go b/sim/stars.go index e17056213..f6e4d2c56 100644 --- a/sim/stars.go +++ b/sim/stars.go @@ -52,7 +52,6 @@ type FacilityAdaptation struct { Range float32 `json:"range"` Scratchpads map[string]string `json:"scratchpads"` SignificantPoints map[string]SignificantPoint `json:"significant_points"` - SimulatePilotAltimeter bool `json:"simulate_pilot_altimeter"` // Airpsace filters Filters struct { From b185e488adc36f1efd709d6fba8ebcf74a3bde1a Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:21:22 -0400 Subject: [PATCH 17/27] sim: show pilot altimeter and indicated altitude in paused hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the sim is paused, hovering an aircraft now shows the pilot's set altimeter (inHg) and their indicated altitude alongside the existing nav summary — useful for debugging altimeter bias behavior. --- sim/sim.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sim/sim.go b/sim/sim.go index d4fdacc23..e78192e92 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -1244,9 +1244,16 @@ func (s *Sim) GetAircraftDisplayState(callsign av.ADSBCallsign) (AircraftDisplay if ac, ok := s.Aircraft[callsign]; !ok { return AircraftDisplayState{}, ErrNoMatchingFlight } else { + summary := ac.NavSummary(s.wxModel, s.State.SimTime, s.lg) + if ac.PilotAltim > 0 { + bias := s.altimBiasFor(ac) + indicated := ac.Altitude() - bias + summary += fmt.Sprintf("\nPilot altimeter %.2f inHg, indicated altitude %.0f", + ac.PilotAltim, indicated) + } return AircraftDisplayState{ Spew: godump.DumpStr(ac), - FlightState: ac.NavSummary(s.wxModel, s.State.SimTime, s.lg), + FlightState: summary, }, nil } } From 4bebc24dbc016cf3893a475ee305f908c447611f Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:45:43 -0400 Subject: [PATCH 18/27] sim: collapse repeated PilotAltim+SetAt writes into a helper Add Aircraft.setPilotAltim(simTime, inHg) and route the six existing call sites through it. Also split initPilotAltim's value selection into initialPilotAltimValue so the write happens exactly once instead of in every branch. --- sim/aircraft.go | 5 +++++ sim/altimeter.go | 34 +++++++++++-------------------- sim/altimeter_integration_test.go | 3 +-- sim/radio.go | 3 +-- 4 files changed, 19 insertions(+), 26 deletions(-) diff --git a/sim/aircraft.go b/sim/aircraft.go index f77e691a1..5b4663077 100644 --- a/sim/aircraft.go +++ b/sim/aircraft.go @@ -152,6 +152,11 @@ type Aircraft struct { TouchAndGosRemaining int // >0 means pattern aircraft; decremented each lap } +func (ac *Aircraft) setPilotAltim(simTime Time, inHg float32) { + ac.PilotAltim = inHg + ac.PilotAltimSetAt = simTime +} + func (ac *Aircraft) GetRadarTrack(now Time) av.RadarTrack { return av.RadarTrack{ ADSBCallsign: ac.ADSBCallsign, diff --git a/sim/altimeter.go b/sim/altimeter.go index e8ca56965..4b2f112ee 100644 --- a/sim/altimeter.go +++ b/sim/altimeter.go @@ -17,39 +17,30 @@ import ( // - Arrival / IFR overflight / VFR with flight-following: nearest METAR at spawn // - VFR overflight without flight-following: 70% nearest, 30% random within 100 NM func (s *Sim) initPilotAltim(ac *Aircraft) { - pos := ac.Nav.FlightState.Position + ac.setPilotAltim(s.State.SimTime, s.initialPilotAltimValue(ac)) +} - wrongEligible := ac.TypeOfFlight == av.FlightTypeOverflight && - ac.FlightPlan.Rules == av.FlightRulesVFR && - !ac.RequestedFlightFollowing +func (s *Sim) initialPilotAltimValue(ac *Aircraft) float32 { + pos := ac.Nav.FlightState.Position if ac.TypeOfFlight == av.FlightTypeDeparture { - // Departure: use the departure airport's METAR if available. if dep := ac.FlightPlan.DepartureAirport; dep != "" { if m, ok := s.State.METAR[dep]; ok { - ac.PilotAltim = m.Altimeter_inHg() - ac.PilotAltimSetAt = s.State.SimTime - return + return m.Altimeter_inHg() } } - // Fallback to nearest METAR. - ac.PilotAltim = s.nearestActualAltim(pos) - ac.PilotAltimSetAt = s.State.SimTime - return + return s.nearestActualAltim(pos) } + wrongEligible := ac.TypeOfFlight == av.FlightTypeOverflight && + ac.FlightPlan.Rules == av.FlightRulesVFR && + !ac.RequestedFlightFollowing if wrongEligible && s.Rand.Float32() < 0.30 { - // 30% chance: pick a random METAR within 100 NM. if alt, ok := s.randomMETARWithin(pos, 100); ok { - ac.PilotAltim = alt - ac.PilotAltimSetAt = s.State.SimTime - return + return alt } } - - // Default: nearest METAR. - ac.PilotAltim = s.nearestActualAltim(pos) - ac.PilotAltimSetAt = s.State.SimTime + return s.nearestActualAltim(pos) } // randomMETARWithin returns the altimeter from a uniformly random METAR @@ -111,8 +102,7 @@ func (s *Sim) tunePilotAltimToATISAirport(ac *Aircraft) { continue } if m, ok := s.State.METAR[icao]; ok { - ac.PilotAltim = m.Altimeter_inHg() - ac.PilotAltimSetAt = s.State.SimTime + ac.setPilotAltim(s.State.SimTime, m.Altimeter_inHg()) return } } diff --git a/sim/altimeter_integration_test.go b/sim/altimeter_integration_test.go index 324f5010a..5b442630d 100644 --- a/sim/altimeter_integration_test.go +++ b/sim/altimeter_integration_test.go @@ -65,8 +65,7 @@ func TestAltimeterBiasShiftsScopedAltitude(t *testing.T) { ac := newTestAircraftAtAltitude(t, 5000) ac.Nav.FlightState.Position = math.Point2LL{-73.78, 40.64} // KJFK - ac.PilotAltim = 30.05 - ac.PilotAltimSetAt = s.State.SimTime + ac.setPilotAltim(s.State.SimTime, 30.05) if s.Aircraft == nil { s.Aircraft = make(map[av.ADSBCallsign]*Aircraft) } diff --git a/sim/radio.go b/sim/radio.go index 4b3e2c923..2975ac530 100644 --- a/sim/radio.go +++ b/sim/radio.go @@ -334,8 +334,7 @@ func (s *Sim) enqueueEmergencyTransmission(callsign av.ADSBCallsign, tcp TCP, rt // intent so the acknowledgment joins any other readbacks from the same // transmission. func (s *Sim) handleAltimeterSetting(ac *Aircraft, settingHundredths int) av.CommandIntent { - ac.PilotAltim = float32(settingHundredths) / 100 - ac.PilotAltimSetAt = s.State.SimTime + ac.setPilotAltim(s.State.SimTime, float32(settingHundredths)/100) return av.AltimeterReadbackIntent{SettingHundredths: settingHundredths} } From 05ba6d0b20d5db7854a60d2e663b7fcf46f3da3e Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:56:51 -0400 Subject: [PATCH 19/27] sim: make incorrect altimeters configurable in sim creation Adds a SimulateIncorrectAltimeters flag to NewSimRequest / Sim, defaulted to true, with a checkbox in the Simulation Settings panel below the readback error interval. When disabled, initPilotAltim is skipped at spawn and altimBiasFor returns 0. --- cmd/vice/simconfig.go | 2 ++ server/manager.go | 8 ++++++-- sim/altimeter.go | 2 +- sim/altimeter_test.go | 5 ++++- sim/sim.go | 3 +++ sim/spawn.go | 4 +++- 6 files changed, 19 insertions(+), 5 deletions(-) diff --git a/cmd/vice/simconfig.go b/cmd/vice/simconfig.go index 21e42b8ad..22214799f 100644 --- a/cmd/vice/simconfig.go +++ b/cmd/vice/simconfig.go @@ -1326,6 +1326,8 @@ func (c *NewSimConfiguration) DrawConfigurationUI(p platform.Platform, config *C imgui.SetNextItemWidth(200) imgui.SliderFloatV("##errorInterval", &c.PilotErrorInterval, 0, 30, util.Select(c.PilotErrorInterval == 0, "never", "%.1f min"), imgui.SliderFlagsNone) + + imgui.Checkbox("Simulate incorrect altimeters", &c.NewSimRequest.SimulateIncorrectAltimeters) imgui.Spacing() // WEATHER & TIME section diff --git a/server/manager.go b/server/manager.go index d9f5164df..1b3fda912 100644 --- a/server/manager.go +++ b/server/manager.go @@ -137,14 +137,17 @@ type NewSimRequest struct { PilotErrorInterval float32 + SimulateIncorrectAltimeters bool + Initials string // Controller initials (e.g., "XX") Privileged bool } func MakeNewSimRequest() NewSimRequest { return NewSimRequest{ - NewSimName: rand.Make().AdjectiveNoun(), - PilotErrorInterval: 0, + NewSimName: rand.Make().AdjectiveNoun(), + PilotErrorInterval: 0, + SimulateIncorrectAltimeters: true, } } @@ -220,6 +223,7 @@ func (sm *SimManager) makeSimConfiguration(req *NewSimRequest, lg *log.Logger) * DisableTFRRestrictionAreas: sg.FacilityConfig.DisableTFRRestrictionAreas, EnforceUniqueCallsignSuffix: req.EnforceUniqueCallsignSuffix, PilotErrorInterval: req.PilotErrorInterval, + SimulateIncorrectAltimeters: req.SimulateIncorrectAltimeters, DepartureRunways: sc.DepartureRunways, ArrivalRunways: sc.ArrivalRunways, VFRReportingPoints: sg.VFRReportingPoints, diff --git a/sim/altimeter.go b/sim/altimeter.go index 4b2f112ee..967be9c67 100644 --- a/sim/altimeter.go +++ b/sim/altimeter.go @@ -112,7 +112,7 @@ func (s *Sim) tunePilotAltimToATISAirport(ac *Aircraft) { // gating as the per-tick update loop (airborne, below FL180). Returns 0 // when the bias should not apply. func (s *Sim) altimBiasFor(ac *Aircraft) float32 { - if !ac.Nav.IsAirborne() || ac.Altitude() >= 18000 { + if !s.SimulateIncorrectAltimeters || !ac.Nav.IsAirborne() || ac.Altitude() >= 18000 { return 0 } actual := s.nearestActualAltim(ac.Position()) diff --git a/sim/altimeter_test.go b/sim/altimeter_test.go index 97f4907c2..17e38a33e 100644 --- a/sim/altimeter_test.go +++ b/sim/altimeter_test.go @@ -50,7 +50,10 @@ func newTestSimWithMETAR(t *testing.T, settings map[string]float32) *Sim { delete(av.DB.Airports, icao) } }) - return &Sim{State: &CommonState{DynamicState: DynamicState{METAR: metar}}} + return &Sim{ + State: &CommonState{DynamicState: DynamicState{METAR: metar}}, + SimulateIncorrectAltimeters: true, + } } func TestAltimBiasFeet(t *testing.T) { diff --git a/sim/sim.go b/sim/sim.go index e78192e92..15972cf1a 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -78,6 +78,7 @@ type Sim struct { FDAMSystemInhibited bool DisabledFDAMRegions map[string]struct{} // keyed by region ID EnforceUniqueCallsignSuffix bool + SimulateIncorrectAltimeters bool PendingContacts map[TCP][]PendingContact FutureFrequencyChanges []FutureFrequencyChange @@ -161,6 +162,7 @@ type NewSimConfiguration struct { DisableTFRRestrictionAreas bool EnforceUniqueCallsignSuffix bool + SimulateIncorrectAltimeters bool ReportingPoints []av.ReportingPoint MagneticVariation float32 @@ -218,6 +220,7 @@ func NewSim(config NewSimConfiguration, lg *log.Logger) *Sim { ReportingPoints: config.ReportingPoints, EnforceUniqueCallsignSuffix: config.EnforceUniqueCallsignSuffix, + SimulateIncorrectAltimeters: config.SimulateIncorrectAltimeters, PilotErrorInterval: time.Duration(config.PilotErrorInterval * float32(time.Minute)), LastPilotError: NewSimTime(config.StartTime), diff --git a/sim/spawn.go b/sim/spawn.go index ecac1e971..26ffd5dbd 100644 --- a/sim/spawn.go +++ b/sim/spawn.go @@ -394,7 +394,9 @@ func (s *Sim) addAircraftNoLock(ac Aircraft) { } s.Aircraft[ac.ADSBCallsign] = &ac - s.initPilotAltim(&ac) + if s.SimulateIncorrectAltimeters { + s.initPilotAltim(&ac) + } ac.Nav.Prespawn = s.prespawn && ac.FlightPlan.Rules == av.FlightRulesVFR From 357fd2803ac06cbe47d47e8584c7d9828fbc0e90 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:16:07 -0400 Subject: [PATCH 20/27] sim: replace altimeter toggle with probability slider, add faulty transponders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the on/off altimeter checkbox with a probability slider and drops the hardcoded 30% VFR-overflight-no-FF carve-out. All non- departure spawns now roll once against IncorrectAltimeterChance; on a hit, they get a nearby-but-wrong station's altimeter. Also adds a second slider for faulty-transponder simulation. Aircraft that roll a hit get a persistent Mode C encoder offset (signed random magnitude 1-1000 ft) that's fixed for the aircraft's life. The offset is added to TransponderAltitude at GetRadarTrack, so scope display, CA, and MSAW all read the corrupted number — TrueAltitude (used for 3D airspace and visibility checks) stays correct. --- cmd/vice/simconfig.go | 12 +++++++++++- server/manager.go | 13 ++++++++----- sim/aircraft.go | 7 ++++++- sim/altimeter.go | 16 ++++++---------- sim/altimeter_test.go | 6 ++++-- sim/sim.go | 9 ++++++--- sim/spawn.go | 3 ++- sim/transponder.go | 23 +++++++++++++++++++++++ 8 files changed, 66 insertions(+), 23 deletions(-) create mode 100644 sim/transponder.go diff --git a/cmd/vice/simconfig.go b/cmd/vice/simconfig.go index 22214799f..362d66393 100644 --- a/cmd/vice/simconfig.go +++ b/cmd/vice/simconfig.go @@ -1327,7 +1327,17 @@ func (c *NewSimConfiguration) DrawConfigurationUI(p platform.Platform, config *C imgui.SliderFloatV("##errorInterval", &c.PilotErrorInterval, 0, 30, util.Select(c.PilotErrorInterval == 0, "never", "%.1f min"), imgui.SliderFlagsNone) - imgui.Checkbox("Simulate incorrect altimeters", &c.NewSimRequest.SimulateIncorrectAltimeters) + imgui.Text("Incorrect altimeter chance:") + imgui.SameLine() + imgui.SetNextItemWidth(200) + imgui.SliderFloatV("##altimChance", &c.NewSimRequest.IncorrectAltimeterChance, 0, 100, + util.Select(c.NewSimRequest.IncorrectAltimeterChance == 0, "off", "%.0f%%"), imgui.SliderFlagsNone) + + imgui.Text("Faulty transponder chance:") + imgui.SameLine() + imgui.SetNextItemWidth(200) + imgui.SliderFloatV("##xpndrChance", &c.NewSimRequest.FaultyTransponderChance, 0, 100, + util.Select(c.NewSimRequest.FaultyTransponderChance == 0, "off", "%.0f%%"), imgui.SliderFlagsNone) imgui.Spacing() // WEATHER & TIME section diff --git a/server/manager.go b/server/manager.go index 1b3fda912..53ca5fe3e 100644 --- a/server/manager.go +++ b/server/manager.go @@ -137,7 +137,8 @@ type NewSimRequest struct { PilotErrorInterval float32 - SimulateIncorrectAltimeters bool + IncorrectAltimeterChance float32 // 0-100% + FaultyTransponderChance float32 // 0-100% Initials string // Controller initials (e.g., "XX") Privileged bool @@ -145,9 +146,10 @@ type NewSimRequest struct { func MakeNewSimRequest() NewSimRequest { return NewSimRequest{ - NewSimName: rand.Make().AdjectiveNoun(), - PilotErrorInterval: 0, - SimulateIncorrectAltimeters: true, + NewSimName: rand.Make().AdjectiveNoun(), + PilotErrorInterval: 0, + IncorrectAltimeterChance: 30, + FaultyTransponderChance: 0, } } @@ -223,7 +225,8 @@ func (sm *SimManager) makeSimConfiguration(req *NewSimRequest, lg *log.Logger) * DisableTFRRestrictionAreas: sg.FacilityConfig.DisableTFRRestrictionAreas, EnforceUniqueCallsignSuffix: req.EnforceUniqueCallsignSuffix, PilotErrorInterval: req.PilotErrorInterval, - SimulateIncorrectAltimeters: req.SimulateIncorrectAltimeters, + IncorrectAltimeterChance: req.IncorrectAltimeterChance, + FaultyTransponderChance: req.FaultyTransponderChance, DepartureRunways: sc.DepartureRunways, ArrivalRunways: sc.ArrivalRunways, VFRReportingPoints: sg.VFRReportingPoints, diff --git a/sim/aircraft.go b/sim/aircraft.go index 5b4663077..bb0112814 100644 --- a/sim/aircraft.go +++ b/sim/aircraft.go @@ -149,6 +149,11 @@ type Aircraft struct { PilotAltim float32 PilotAltimSetAt Time + // TransponderAltOffset is a persistent Mode C encoder error assigned at + // spawn when the faulty-transponder roll hits. Added to the reported + // altitude on the scope; zero means the encoder is working correctly. + TransponderAltOffset float32 + TouchAndGosRemaining int // >0 means pattern aircraft; decremented each lap } @@ -164,7 +169,7 @@ func (ac *Aircraft) GetRadarTrack(now Time) av.RadarTrack { Mode: ac.Mode, Ident: ac.Mode != av.TransponderModeStandby && now.After(ac.IdentStartTime) && now.Before(ac.IdentEndTime), TrueAltitude: ac.Altitude(), - TransponderAltitude: util.Select(ac.Mode == av.TransponderModeAltitude, ac.Altitude(), 0), + TransponderAltitude: util.Select(ac.Mode == av.TransponderModeAltitude, ac.Altitude()+ac.TransponderAltOffset, 0), Location: ac.Position(), Heading: ac.Heading(), Groundspeed: ac.GS(), diff --git a/sim/altimeter.go b/sim/altimeter.go index 967be9c67..a1a38acd4 100644 --- a/sim/altimeter.go +++ b/sim/altimeter.go @@ -10,12 +10,11 @@ import ( ) // initPilotAltim sets ac.PilotAltim and ac.PilotAltimSetAt according to the -// hybrid spawn rule. +// spawn rule. // -// Categories: -// - Departure: local field altimeter (or nearest METAR if airport has no METAR) -// - Arrival / IFR overflight / VFR with flight-following: nearest METAR at spawn -// - VFR overflight without flight-following: 70% nearest, 30% random within 100 NM +// - Departure: always local field altimeter (pilot just got the tower ATIS). +// - All other aircraft: roll vs. IncorrectAltimeterChance — on a hit, use a +// different nearby station's altimeter; otherwise use the correct local one. func (s *Sim) initPilotAltim(ac *Aircraft) { ac.setPilotAltim(s.State.SimTime, s.initialPilotAltimValue(ac)) } @@ -32,10 +31,7 @@ func (s *Sim) initialPilotAltimValue(ac *Aircraft) float32 { return s.nearestActualAltim(pos) } - wrongEligible := ac.TypeOfFlight == av.FlightTypeOverflight && - ac.FlightPlan.Rules == av.FlightRulesVFR && - !ac.RequestedFlightFollowing - if wrongEligible && s.Rand.Float32() < 0.30 { + if s.IncorrectAltimeterChance > 0 && s.Rand.Float32()*100 < s.IncorrectAltimeterChance { if alt, ok := s.randomMETARWithin(pos, 100); ok { return alt } @@ -112,7 +108,7 @@ func (s *Sim) tunePilotAltimToATISAirport(ac *Aircraft) { // gating as the per-tick update loop (airborne, below FL180). Returns 0 // when the bias should not apply. func (s *Sim) altimBiasFor(ac *Aircraft) float32 { - if !s.SimulateIncorrectAltimeters || !ac.Nav.IsAirborne() || ac.Altitude() >= 18000 { + if s.IncorrectAltimeterChance == 0 || !ac.Nav.IsAirborne() || ac.Altitude() >= 18000 { return 0 } actual := s.nearestActualAltim(ac.Position()) diff --git a/sim/altimeter_test.go b/sim/altimeter_test.go index 17e38a33e..2e11d104e 100644 --- a/sim/altimeter_test.go +++ b/sim/altimeter_test.go @@ -9,6 +9,7 @@ import ( av "github.com/mmp/vice/aviation" "github.com/mmp/vice/math" + "github.com/mmp/vice/rand" "github.com/mmp/vice/wx" ) @@ -51,8 +52,9 @@ func newTestSimWithMETAR(t *testing.T, settings map[string]float32) *Sim { } }) return &Sim{ - State: &CommonState{DynamicState: DynamicState{METAR: metar}}, - SimulateIncorrectAltimeters: true, + State: &CommonState{DynamicState: DynamicState{METAR: metar}}, + IncorrectAltimeterChance: 100, + Rand: rand.Make(), } } diff --git a/sim/sim.go b/sim/sim.go index 15972cf1a..dc8f8c832 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -78,7 +78,8 @@ type Sim struct { FDAMSystemInhibited bool DisabledFDAMRegions map[string]struct{} // keyed by region ID EnforceUniqueCallsignSuffix bool - SimulateIncorrectAltimeters bool + IncorrectAltimeterChance float32 // 0-100% chance a non-departure spawns with a nearby-but-wrong altimeter + FaultyTransponderChance float32 // 0-100% chance an aircraft spawns with a persistent Mode C offset PendingContacts map[TCP][]PendingContact FutureFrequencyChanges []FutureFrequencyChange @@ -162,7 +163,8 @@ type NewSimConfiguration struct { DisableTFRRestrictionAreas bool EnforceUniqueCallsignSuffix bool - SimulateIncorrectAltimeters bool + IncorrectAltimeterChance float32 + FaultyTransponderChance float32 ReportingPoints []av.ReportingPoint MagneticVariation float32 @@ -220,7 +222,8 @@ func NewSim(config NewSimConfiguration, lg *log.Logger) *Sim { ReportingPoints: config.ReportingPoints, EnforceUniqueCallsignSuffix: config.EnforceUniqueCallsignSuffix, - SimulateIncorrectAltimeters: config.SimulateIncorrectAltimeters, + IncorrectAltimeterChance: config.IncorrectAltimeterChance, + FaultyTransponderChance: config.FaultyTransponderChance, PilotErrorInterval: time.Duration(config.PilotErrorInterval * float32(time.Minute)), LastPilotError: NewSimTime(config.StartTime), diff --git a/sim/spawn.go b/sim/spawn.go index 26ffd5dbd..3d2157388 100644 --- a/sim/spawn.go +++ b/sim/spawn.go @@ -394,9 +394,10 @@ func (s *Sim) addAircraftNoLock(ac Aircraft) { } s.Aircraft[ac.ADSBCallsign] = &ac - if s.SimulateIncorrectAltimeters { + if s.IncorrectAltimeterChance > 0 { s.initPilotAltim(&ac) } + s.initTransponderFault(&ac) ac.Nav.Prespawn = s.prespawn && ac.FlightPlan.Rules == av.FlightRulesVFR diff --git a/sim/transponder.go b/sim/transponder.go new file mode 100644 index 000000000..7a7ccaa87 --- /dev/null +++ b/sim/transponder.go @@ -0,0 +1,23 @@ +// sim/transponder.go +// Copyright(c) 2022-2025 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package sim + +// initTransponderFault rolls against FaultyTransponderChance at spawn; on a +// hit, assigns a persistent Mode C offset (signed, magnitude 1-1000 ft) that +// stays fixed for the aircraft's life. The offset is applied wherever the +// scope reads Mode C altitude. +func (s *Sim) initTransponderFault(ac *Aircraft) { + if s.FaultyTransponderChance <= 0 { + return + } + if s.Rand.Float32()*100 >= s.FaultyTransponderChance { + return + } + magnitude := 1 + s.Rand.Float32()*999 + if s.Rand.Float32() < 0.5 { + magnitude = -magnitude + } + ac.TransponderAltOffset = magnitude +} From 401e62b26a36bd2227deb43b34a1b57791db875b Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:33:30 -0400 Subject: [PATCH 21/27] sim: default faulty transponder chance to 5% Match the probability gate on incorrect altimeters, which defaults to 30%. At 0 there's no simulation at all, so pick a small but non-zero default so new sims exercise the Mode C offset path without being overwhelming. --- server/manager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/manager.go b/server/manager.go index 53ca5fe3e..cbcf333fc 100644 --- a/server/manager.go +++ b/server/manager.go @@ -149,7 +149,7 @@ func MakeNewSimRequest() NewSimRequest { NewSimName: rand.Make().AdjectiveNoun(), PilotErrorInterval: 0, IncorrectAltimeterChance: 30, - FaultyTransponderChance: 0, + FaultyTransponderChance: 5, } } From b31a1014febd6c256a15e8f83b011769757d47a9 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:33:40 -0400 Subject: [PATCH 22/27] sim+stt+nav+av: add stop-altitude-squawk and report-reaching commands "Stop altitude squawk" gives controllers the proper phraseology for responding to a Mode C altitude mismatch: the aircraft switches from Mode C to Mode A and the readback uses the specific phrase rather than the generic "squawk on" readback. An optional "altitude differs NNN feet" tail is accepted and ignored. "Report reaching {altitude}" installs a pending target on the nav state. A new altitude assignment or a new report-reaching request supersedes any pending target. When the aircraft levels off within 100 ft of the target (AltitudeRate clamped to 0), the sim enqueues an unsolicited "reaching {alt}" pilot transmission and clears the target. --- aviation/intent.go | 32 ++++++++++++++++++++++++++++++++ nav/commands.go | 3 +++ nav/nav.go | 6 ++++++ sim/command_parser.go | 8 ++++++++ sim/radio.go | 21 +++++++++++++++++++++ sim/sim.go | 15 +++++++++++++++ sim/vfr.go | 30 ++++++++++++++++++++++++++++++ stt/handlers.go | 27 +++++++++++++++++++++++++++ 8 files changed, 142 insertions(+) diff --git a/aviation/intent.go b/aviation/intent.go index 35c362549..0fc91649b 100644 --- a/aviation/intent.go +++ b/aviation/intent.go @@ -859,6 +859,38 @@ func (t TransponderIntent) Render(rt *RadioTransmission, r *rand.Rand) { } } +// StopAltitudeSquawkIntent represents the pilot acknowledging a "stop +// altitude squawk" command (controller has observed the Mode C readout +// disagrees with the pilot-reported altitude). The aircraft switches from +// Mode C to Mode A. +type StopAltitudeSquawkIntent struct{} + +func (StopAltitudeSquawkIntent) Render(rt *RadioTransmission, r *rand.Rand) { + rt.Add("[stopping altitude squawk|stop altitude squawk]") +} + +// ReportReachingIntent represents the pilot acknowledging a "report +// reaching (altitude)" instruction. The actual reaching-the-altitude call +// is issued later when the aircraft levels off. +type ReportReachingIntent struct { + Altitude float32 +} + +func (r ReportReachingIntent) Render(rt *RadioTransmission, rnd *rand.Rand) { + rt.Add("[wilco|will do|will report reaching {alt}]", r.Altitude) +} + +// ReachingAltitudeIntent is the unsolicited pilot transmission fired when +// the aircraft levels off at a previously requested "report reaching" +// altitude. +type ReachingAltitudeIntent struct { + Altitude float32 +} + +func (r ReachingAltitudeIntent) Render(rt *RadioTransmission, rnd *rand.Rand) { + rt.Add("[reaching|leveling off at|level] {alt}", r.Altitude) +} + /////////////////////////////////////////////////////////////////////////// // Special Intents diff --git a/nav/commands.go b/nav/commands.go index cc6ec2cb3..04b180daf 100644 --- a/nav/commands.go +++ b/nav/commands.go @@ -32,6 +32,9 @@ func (nav *Nav) AssignAltitude(alt float32, afterSpeed bool, simTime Time, delay if !ok { return intent } + // A new controller-issued altitude supersedes any pending "report + // reaching" target. + nav.ReportReachingAltitude = nil nav.enqueueAssignedAltitude(alt, simTime, delayReduction) return intent } diff --git a/nav/nav.go b/nav/nav.go index 349c297cd..15bb6ebe5 100644 --- a/nav/nav.go +++ b/nav/nav.go @@ -67,6 +67,12 @@ type Nav struct { PendingWaypointActionEvents []av.WaypointActionEvent + // ReportReachingAltitude stores the most-recent "report reaching NNNN" + // target. Cleared when a new altitude is assigned, and cleared when the + // aircraft levels off within tolerance (the leveling-off triggers a + // reaching-altitude transmission). + ReportReachingAltitude *float32 + Rand *rand.Rand } diff --git a/sim/command_parser.go b/sim/command_parser.go index b7f3b9d94..b9f5a0b62 100644 --- a/sim/command_parser.go +++ b/sim/command_parser.go @@ -837,6 +837,14 @@ func (s *Sim) runOneControlCommand(tcw TCW, callsign av.ADSBCallsign, command st return s.ChangeTransponderMode(tcw, callsign, av.TransponderModeAltitude) } else if command == "SQON" { return s.ChangeTransponderMode(tcw, callsign, av.TransponderModeOn) + } else if command == "SQSTOP" { + return s.StopAltitudeSquawk(tcw, callsign) + } else if strings.HasPrefix(command, "RR") { + alt, err := strconv.Atoi(command[2:]) + if err != nil { + return nil, err + } + return s.ReportReaching(tcw, callsign, float32(alt)) } else if len(command) == 6 && command[:2] == "SQ" { sq, err := av.ParseSquawk(command[2:]) if err != nil { diff --git a/sim/radio.go b/sim/radio.go index 2975ac530..2fb070347 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 + PendingTransmissionReachingAltitude // Pilot reporting reaching a previously requested altitude ) // FutureFrequencyChange represents a pilot switching to a new frequency. @@ -329,6 +330,19 @@ func (s *Sim) enqueueEmergencyTransmission(callsign av.ADSBCallsign, tcp TCP, rt }) } +// enqueueReachingAltitudeTransmission enqueues the unsolicited "reaching +// NNNN" pilot call fired when an aircraft levels off at a previously +// requested "report reaching" altitude. The message is built at trigger +// time since the exact altitude is captured when we detect level-off. +func (s *Sim) enqueueReachingAltitudeTransmission(callsign av.ADSBCallsign, tcp TCP, rt *av.RadioTransmission) { + s.addPendingContact(PendingContact{ + ADSBCallsign: callsign, + TCP: tcp, + Type: PendingTransmissionReachingAltitude, + PrebuiltTransmission: rt, + }) +} + // handleAltimeterSetting processes an "altimeter X.XX" command issued by a // controller. Mutates the pilot's altimeter setting and returns a readback // intent so the acknowledgment joins any other readbacks from the same @@ -492,6 +506,13 @@ func (s *Sim) GenerateContactTransmission(pc *PendingContact) (spokenText, writt rt = pc.PrebuiltTransmission rt.Type = av.RadioTransmissionUnexpected // Mark as urgent for display + case PendingTransmissionReachingAltitude: + if pc.PrebuiltTransmission == nil { + return "", "" + } + rt = pc.PrebuiltTransmission + rt.Type = av.RadioTransmissionUnexpected + case PendingTransmissionRequestVisual: // If the aircraft was cleared for an approach between enqueue and // dispatch, drop the now-redundant visual approach request. diff --git a/sim/sim.go b/sim/sim.go index dc8f8c832..a6a74f65b 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -994,6 +994,21 @@ func (s *Sim) updateState() { s.enqueuePilotTransmission(callsign, TCP(ac.ControllerFrequency), PendingTransmissionRequestVectors) } + // "Report reaching {altitude}" — fire the unsolicited pilot call + // when the aircraft has leveled off within tolerance of the + // requested altitude. AltitudeRate is clamped to 0 once the nav + // system settles on level flight, so an exact-zero check plus a + // loose altitude tolerance catches the level-off. + if ac.Nav.ReportReachingAltitude != nil && ac.IsAssociated() { + target := *ac.Nav.ReportReachingAltitude + if ac.Nav.FlightState.AltitudeRate == 0 && math.Abs(ac.Altitude()-target) < 100 { + ac.Nav.ReportReachingAltitude = nil + rt := av.MakeContactTransmission("") + av.ReachingAltitudeIntent{Altitude: target}.Render(rt, s.Rand) + s.enqueueReachingAltitudeTransmission(callsign, TCP(ac.ControllerFrequency), rt) + } + } + if ac.Nav.Approach.RequestAltitude && ac.IsAssociated() { ac.Nav.Approach.RequestAltitude = false if ac.Nav.Altitude.Assigned == nil && ac.Nav.Altitude.AfterSpeed == nil { diff --git a/sim/vfr.go b/sim/vfr.go index f331b4cb3..60d4106b8 100644 --- a/sim/vfr.go +++ b/sim/vfr.go @@ -276,6 +276,36 @@ func (s *Sim) ChangeTransponderMode(tcw TCW, callsign av.ADSBCallsign, mode av.T }) } +// StopAltitudeSquawk handles the "stop altitude squawk" command. Aircraft +// switches from Mode C to Mode A; readback uses the specific phraseology +// rather than the generic "squawk on". +func (s *Sim) StopAltitudeSquawk(tcw TCW, callsign av.ADSBCallsign) (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 { + s.enqueueTransponderChange(ac.ADSBCallsign, ac.Squawk, av.TransponderModeOn) + return av.StopAltitudeSquawkIntent{} + }) +} + +// ReportReaching handles the "report reaching {altitude}" command. The +// pilot acknowledges immediately; the actual "reaching" call is fired +// later by the per-tick nav update when the aircraft levels off at the +// target. Only one pending target is tracked; a new request replaces it, +// as does any new altitude assignment. +func (s *Sim) ReportReaching(tcw TCW, callsign av.ADSBCallsign, altitude float32) (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 { + ac.Nav.ReportReachingAltitude = &altitude + return av.ReportReachingIntent{Altitude: altitude} + }) +} + func (s *Sim) Ident(tcw TCW, callsign av.ADSBCallsign) (av.CommandIntent, error) { s.mu.Lock(s.lg) defer s.mu.Unlock(s.lg) diff --git a/stt/handlers.go b/stt/handlers.go index bff96c189..83c7ef371 100644 --- a/stt/handlers.go +++ b/stt/handlers.go @@ -1469,6 +1469,33 @@ func registerAllCommands() { WithPriority(12), ) + // "Stop altitude squawk" — controller has observed that Mode C altitude + // disagrees with pilot-reported altitude. Aircraft switches to Mode A. + // The "altitude differs NNN feet" tail is absorbed as informational. + registerSTTCommand( + "stop altitude squawk [altitude differs {num:1-9999} [hundred|thousand] [feet|foot]]", + func(_ *int) string { return "SQSTOP" }, + WithName("stop_altitude_squawk_with_delta"), + WithPriority(13), + ) + registerSTTCommand( + "stop altitude squawk", + func() string { return "SQSTOP" }, + WithName("stop_altitude_squawk"), + WithPriority(12), + ) + + // "Report reaching {altitude}" — pilot acknowledges now, transmits the + // actual "reaching" call later when the aircraft levels off. Both the + // "report" lead-in and a "reaching|leveling off at|level at" body are + // required so we don't fuzzy-match spurious "approach {alt}" fragments. + registerSTTCommand( + "report reaching|leveling|level {altitude}", + func(alt int) string { return fmt.Sprintf("RR%d", alt) }, + WithName("report_reaching"), + WithPriority(10), + ) + registerSTTCommand( "squawk vfr|victor", func() string { return "" }, // Ignored - VFR squawk is informational From c73ffb8433a1db6bf65cf2dd865db82583045ad7 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:41:06 -0400 Subject: [PATCH 23/27] sim: dispatch RR{alt} under case 'R' and scale altitude The RR dispatch was mistakenly placed inside case 'S', so "RR50" (command[0]=='R') fell through to the turn-right heading parser, which either errored or produced a spurious heading. Move it under case 'R' with the same 100-ft scaling as the altitude assignments, so "report reaching 5000" correctly targets 5000 ft instead of 50 ft. --- sim/command_parser.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/sim/command_parser.go b/sim/command_parser.go index b9f5a0b62..d968307fc 100644 --- a/sim/command_parser.go +++ b/sim/command_parser.go @@ -789,6 +789,15 @@ 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, "RR") && len(command) > 2 && util.IsAllNumbers(command[2:]) { + alt, err := strconv.Atoi(command[2:]) + if err != nil { + return nil, err + } + if alt > 600 && alt%100 == 0 { + alt /= 100 + } + return s.ReportReaching(tcw, callsign, float32(100*alt)) } 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' { @@ -839,12 +848,6 @@ func (s *Sim) runOneControlCommand(tcw TCW, callsign av.ADSBCallsign, command st return s.ChangeTransponderMode(tcw, callsign, av.TransponderModeOn) } else if command == "SQSTOP" { return s.StopAltitudeSquawk(tcw, callsign) - } else if strings.HasPrefix(command, "RR") { - alt, err := strconv.Atoi(command[2:]) - if err != nil { - return nil, err - } - return s.ReportReaching(tcw, callsign, float32(alt)) } else if len(command) == 6 && command[:2] == "SQ" { sq, err := av.ParseSquawk(command[2:]) if err != nil { From e9a745b3337e65636d52034d24150fbe7459a23b Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:43:59 -0400 Subject: [PATCH 24/27] sim: don't flag reaching-altitude report as unexpected The pilot's "reaching NNNN" call is fulfilling a controller-requested report, not an out-of-turn urgent transmission, so it shouldn't render in the unexpected/red style. Leave it at the default RadioTransmissionContact type that MakeContactTransmission sets. --- sim/radio.go | 1 - 1 file changed, 1 deletion(-) diff --git a/sim/radio.go b/sim/radio.go index 2fb070347..7696dc906 100644 --- a/sim/radio.go +++ b/sim/radio.go @@ -511,7 +511,6 @@ func (s *Sim) GenerateContactTransmission(pc *PendingContact) (spokenText, writt return "", "" } rt = pc.PrebuiltTransmission - rt.Type = av.RadioTransmissionUnexpected case PendingTransmissionRequestVisual: // If the aircraft was cleared for an approach between enqueue and From e92a232fc650112791f567d67bc795a3e559d900 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:47:53 -0400 Subject: [PATCH 25/27] sim: render reaching-altitude report as a readback-style advisory Using MakeContactTransmission tags it as a first-contact call, which the formatter prefixes with "{controller}, {full callsign}, ...". A report-reaching report is a brief advisory on an already-established frequency, so switch to MakeReadbackTransmission, which just appends the abbreviated callsign at the end. --- sim/sim.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sim/sim.go b/sim/sim.go index a6a74f65b..b69b7d7b0 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -1003,7 +1003,7 @@ func (s *Sim) updateState() { target := *ac.Nav.ReportReachingAltitude if ac.Nav.FlightState.AltitudeRate == 0 && math.Abs(ac.Altitude()-target) < 100 { ac.Nav.ReportReachingAltitude = nil - rt := av.MakeContactTransmission("") + rt := av.MakeReadbackTransmission("") av.ReachingAltitudeIntent{Altitude: target}.Render(rt, s.Rand) s.enqueueReachingAltitudeTransmission(callsign, TCP(ac.ControllerFrequency), rt) } From 2719cb086b4f06accffa1fa0e5da689f453dd9cb Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:18:38 -0400 Subject: [PATCH 26/27] stt: block say<->stop fuzzy match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "say altitude" was being misrouted to "stop altitude squawk" because "say" and "stop" both start with 's' and Jaro-Winkler's prefix bonus was enough to fuzzy-match them — particularly since the stop-squawk template has higher priority. Add both directions to the fuzzy-match blocklist so the say-altitude command isn't captured. --- stt/similarity.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stt/similarity.go b/stt/similarity.go index 1a42cf4a2..a79fb3699 100644 --- a/stt/similarity.go +++ b/stt/similarity.go @@ -474,6 +474,8 @@ var fuzzyMatchBlocklist = map[string][]string{ "maintained": {"maintain"}, // STT echo after "maintain" should not re-match "hitting": {"heading"}, // garbled word should not match heading command "information": {"uniform"}, // "information X" is the ATIS keyword, not NATO letter U + "say": {"stop"}, // "say altitude" vs "stop altitude squawk" + "stop": {"say"}, // "stop altitude squawk" vs "say altitude" } // FuzzyMatch returns true if word matches target with Jaro-Winkler >= threshold From a015c2fb0873cdec3811f4fb80cf697ca47a0522 Mon Sep 17 00:00:00 2001 From: Jud6969 <155589188+Jud6969@users.noreply.github.com> Date: Mon, 18 May 2026 09:21:27 -0400 Subject: [PATCH 27/27] nav: pass 0 altimBiasFeet to UpdateWithWeather in hold tests The hold_test.go file was added on upstream/master after this branch diverged, so the altimeter-rebase didn't sweep its UpdateWithWeather call sites. Pass 0 to match the new signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- nav/hold_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nav/hold_test.go b/nav/hold_test.go index 095e63ac8..8320dc0d3 100644 --- a/nav/hold_test.go +++ b/nav/hold_test.go @@ -52,7 +52,7 @@ func TestHoldTurningInboundDoesNotFlyAwayAfterOvershoot(t *testing.T) { }} f.nav.UpdateWithWeather(f.callsign, wx.MakeStandardSampleForAltitude(f.nav.FlightState.Altitude), - &f.fp, f.simTime, nil) + &f.fp, 0, f.simTime, nil) f.simTime = f.simTime.Add(time.Second) if f.nav.Heading.Hold == nil { t.Fatal("hold unexpectedly ended") @@ -65,7 +65,7 @@ func TestHoldTurningInboundDoesNotFlyAwayAfterOvershoot(t *testing.T) { for range 90 { f.nav.UpdateWithWeather(f.callsign, wx.MakeStandardSampleForAltitude(f.nav.FlightState.Altitude), - &f.fp, f.simTime, nil) + &f.fp, 0, f.simTime, nil) f.simTime = f.simTime.Add(time.Second) } @@ -124,7 +124,7 @@ func TestHoldInboundTurnDistanceMatchesOutboundTurn(t *testing.T) { previousStep := hold.currentStep() for tick := range 300 { - f.nav.UpdateWithWeather(f.callsign, f.weather(f.nav.FlightState.Altitude), &f.fp, f.simTime, nil) + f.nav.UpdateWithWeather(f.callsign, f.weather(f.nav.FlightState.Altitude), &f.fp, 0, f.simTime, nil) f.simTime = f.simTime.Add(time.Second) step := hold.currentStep() @@ -191,7 +191,7 @@ func TestFQM3HoldInboundTurnCompletesNearExpectedTrack(t *testing.T) { flyFixStep := "fly toward fix until fix" for tick := range 2000 { - f.nav.UpdateWithWeather(f.callsign, f.weather(f.nav.FlightState.Altitude), &f.fp, f.simTime, nil) + f.nav.UpdateWithWeather(f.callsign, f.weather(f.nav.FlightState.Altitude), &f.fp, 0, f.simTime, nil) f.simTime = f.simTime.Add(time.Second) if f.nav.Heading.Hold != nil && hold == nil { @@ -311,7 +311,7 @@ func TestHoldInboundTurnCompletesAfterHalfCircuitWithStrongWind(t *testing.T) { inboundTurnStartTick := -1 for tick := range 240 { - f.nav.UpdateWithWeather(f.callsign, f.weather(f.nav.FlightState.Altitude), &f.fp, f.simTime, nil) + f.nav.UpdateWithWeather(f.callsign, f.weather(f.nav.FlightState.Altitude), &f.fp, 0, f.simTime, nil) f.simTime = f.simTime.Add(time.Second) step := hold.currentStep()