diff --git a/aviation/intent.go b/aviation/intent.go index 99aae47cb..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 @@ -1018,6 +1050,20 @@ 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". +type AltimeterReadbackIntent struct { + SettingHundredths int // e.g., 3002 for 30.02 +} + +func (a AltimeterReadbackIntent) Render(rt *RadioTransmission, r *rand.Rand) { + digits := sayDigits(a.SettingHundredths, 4) + rt.Add("[altimeter " + digits + "|" + digits + "|roger " + digits + "]") +} + /////////////////////////////////////////////////////////////////////////// // LookForFieldIntent diff --git a/cmd/vice/simconfig.go b/cmd/vice/simconfig.go index 21e42b8ad..362d66393 100644 --- a/cmd/vice/simconfig.go +++ b/cmd/vice/simconfig.go @@ -1326,6 +1326,18 @@ 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.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/nav/alt_test.go b/nav/alt_test.go index 9cf3a2aa3..09e3ca2ee 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 @@ -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) } @@ -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.go b/nav/commands.go index c7d688854..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 } @@ -346,14 +349,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/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/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() 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.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/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/server/manager.go b/server/manager.go index d9f5164df..cbcf333fc 100644 --- a/server/manager.go +++ b/server/manager.go @@ -137,14 +137,19 @@ type NewSimRequest struct { PilotErrorInterval float32 + IncorrectAltimeterChance float32 // 0-100% + FaultyTransponderChance float32 // 0-100% + 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, + IncorrectAltimeterChance: 30, + FaultyTransponderChance: 5, } } @@ -220,6 +225,8 @@ func (sm *SimManager) makeSimConfiguration(req *NewSimRequest, lg *log.Logger) * DisableTFRRestrictionAreas: sg.FacilityConfig.DisableTFRRestrictionAreas, EnforceUniqueCallsignSuffix: req.EnforceUniqueCallsignSuffix, PilotErrorInterval: req.PilotErrorInterval, + 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 14aac4693..bb0112814 100644 --- a/sim/aircraft.go +++ b/sim/aircraft.go @@ -144,9 +144,24 @@ 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 + + // 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 } +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, @@ -154,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(), @@ -286,12 +301,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)) } @@ -356,8 +371,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 new file mode 100644 index 000000000..a1a38acd4 --- /dev/null +++ b/sim/altimeter.go @@ -0,0 +1,136 @@ +// 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" +) + +// initPilotAltim sets ac.PilotAltim and ac.PilotAltimSetAt according to the +// spawn rule. +// +// - 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)) +} + +func (s *Sim) initialPilotAltimValue(ac *Aircraft) float32 { + pos := ac.Nav.FlightState.Position + + if ac.TypeOfFlight == av.FlightTypeDeparture { + if dep := ac.FlightPlan.DepartureAirport; dep != "" { + if m, ok := s.State.METAR[dep]; ok { + return m.Altimeter_inHg() + } + } + return s.nearestActualAltim(pos) + } + + if s.IncorrectAltimeterChance > 0 && s.Rand.Float32()*100 < s.IncorrectAltimeterChance { + if alt, ok := s.randomMETARWithin(pos, 100); ok { + return alt + } + } + return s.nearestActualAltim(pos) +} + +// 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. +func altimBiasFeet(nearestActualInHg, pilotInHg float32) float32 { + if pilotInHg == 0 { + return 0 + } + 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 no METAR is available. +func (s *Sim) tunePilotAltimToATISAirport(ac *Aircraft) { + 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.setPilotAltim(s.State.SimTime, m.Altimeter_inHg()) + return + } + } +} + +// altimBiasFor returns the current altimeter bias for ac, applying the same +// 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.IncorrectAltimeterChance == 0 || !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". +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_integration_test.go b/sim/altimeter_integration_test.go new file mode 100644 index 000000000..5b442630d --- /dev/null +++ b/sim/altimeter_integration_test.go @@ -0,0 +1,112 @@ +// 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 { + ac.Update(stubModel, s.altimBiasFor(ac), 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 + }) + // 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.setPilotAltim(s.State.SimTime, 30.05) + 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. 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) + } + + 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 new file mode 100644 index 000000000..2e11d104e --- /dev/null +++ b/sim/altimeter_test.go @@ -0,0 +1,102 @@ +// 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" + + av "github.com/mmp/vice/aviation" + "github.com/mmp/vice/math" + "github.com/mmp/vice/rand" + "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}, + "KABE": {-75.44, 40.65}, +} + +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}}, + IncorrectAltimeterChance: 100, + Rand: rand.Make(), + } +} + +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) + } +} + +func TestInitPilotAltimSetsCorrectForArrival(t *testing.T) { + s := newTestSimWithMETAR(t, map[string]float32{"KJFK": 30.05}) + 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/command_parser.go b/sim/command_parser.go index 174f05ad7..d968307fc 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 { + return s.handleAltimeterSetting(ac, setting), nil + } + return nil, nil } else { components := strings.Split(command, "/") if len(components) != 2 || len(components[1]) == 0 { @@ -778,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' { @@ -826,6 +846,8 @@ 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 len(command) == 6 && command[:2] == "SQ" { sq, err := av.ParseSquawk(command[2:]) if err != nil { diff --git a/sim/commands.go b/sim/commands.go index c60ac859f..9829f19f6 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)) }) } @@ -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} }) } diff --git a/sim/radio.go b/sim/radio.go index ea0015485..7696dc906 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,28 @@ 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 +// transmission. +func (s *Sim) handleAltimeterSetting(ac *Aircraft, settingHundredths int) av.CommandIntent { + ac.setPilotAltim(s.State.SimTime, float32(settingHundredths)/100) + return av.AltimeterReadbackIntent{SettingHundredths: 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. @@ -483,6 +506,12 @@ 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 + 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 68c1b3b92..b69b7d7b0 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -78,6 +78,8 @@ type Sim struct { FDAMSystemInhibited bool DisabledFDAMRegions map[string]struct{} // keyed by region ID EnforceUniqueCallsignSuffix 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 @@ -161,6 +163,8 @@ type NewSimConfiguration struct { DisableTFRRestrictionAreas bool EnforceUniqueCallsignSuffix bool + IncorrectAltimeterChance float32 + FaultyTransponderChance float32 ReportingPoints []av.ReportingPoint MagneticVariation float32 @@ -218,6 +222,8 @@ func NewSim(config NewSimConfiguration, lg *log.Logger) *Sim { ReportingPoints: config.ReportingPoints, EnforceUniqueCallsignSuffix: config.EnforceUniqueCallsignSuffix, + IncorrectAltimeterChance: config.IncorrectAltimeterChance, + FaultyTransponderChance: config.FaultyTransponderChance, PilotErrorInterval: time.Duration(config.PilotErrorInterval * float32(time.Minute)), LastPilotError: NewSimTime(config.StartTime), @@ -968,7 +974,8 @@ func (s *Sim) updateState() { continue } - updateResult := ac.Update(s.wxModel, s.State.SimTime, s.bravoAirspace, nil /* s.lg*/) + bias := s.altimBiasFor(ac) + updateResult := ac.Update(s.wxModel, bias, s.State.SimTime, s.bravoAirspace, nil /* s.lg*/) passedWaypoint := updateResult.PassedWaypoint s.refreshSeenTraffic(ac) @@ -987,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.MakeReadbackTransmission("") + 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 { @@ -1243,9 +1265,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 } } diff --git a/sim/spawn.go b/sim/spawn.go index d624d3b14..3d2157388 100644 --- a/sim/spawn.go +++ b/sim/spawn.go @@ -394,6 +394,10 @@ func (s *Sim) addAircraftNoLock(ac Aircraft) { } s.Aircraft[ac.ADSBCallsign] = &ac + if s.IncorrectAltimeterChance > 0 { + s.initPilotAltim(&ac) + } + s.initTransponderFault(&ac) ac.Nav.Prespawn = s.prespawn && ac.FlightPlan.Rules == av.FlightRulesVFR 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 } 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 +} 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 diff --git a/stt/provider.go b/stt/provider.go index 8a49a1afd..771969c74 100644 --- a/stt/provider.go +++ b/stt/provider.go @@ -1,6 +1,8 @@ package stt import ( + "fmt" + "strconv" "strings" "time" @@ -164,12 +166,23 @@ 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 // 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) @@ -206,6 +219,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 { @@ -1031,12 +1051,16 @@ 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 { +// 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 setting was found. +func stripInformational(tokens []Token) ([]Token, int, bool) { tokens = stripPositionIDPrefix(tokens) tokens = stripRadarContactPrefix(tokens) - tokens = stripAltimeterSuffix(tokens) - return tokens + var altimSetting int + var altimOK bool + tokens, altimSetting, altimOK = extractAltimeterSetting(tokens) + return tokens, altimSetting, altimOK } // stripPositionIDPrefix removes a controller position identification prefix @@ -1093,18 +1117,27 @@ 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 { +// 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 "altimeter" keyword (fuzzy-matched to tolerate +// STT mis-transcriptions like "altometer", "altimeters"): +// - one number token: "3002" → 3002 +// - 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" { + if !isAltimeterKeyword(t.Text) { continue } - if i+1 >= len(tokens) || tokens[i+1].Type != TokenNumber { - continue - } - if i+2 < len(tokens) { + hundredths, consumed, ok := parseAltimeterForms(tokens[i+1:]) + if !ok { continue } start := i @@ -1112,10 +1145,74 @@ func stripAltimeterSuffix(tokens []Token) []Token { !IsCommandKeyword(strings.ToLower(tokens[start-1].Text)) { start-- } - logLocalStt("stripped altimeter suffix: %d tokens", len(tokens)-start) - return tokens[:start] + 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 + 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) { + 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..5cbdbcba3 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,58 @@ func TestTokenize(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, "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) + 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) + } + }) + } +} + // Benchmark for performance verification func BenchmarkDecodeTranscript(b *testing.B) { 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 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/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_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/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", 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",