diff --git a/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/FMC/A32NX_FMCMainDisplay.js b/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/FMC/A32NX_FMCMainDisplay.js index bf89ed689a71..f3760469e33b 100644 --- a/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/FMC/A32NX_FMCMainDisplay.js +++ b/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/FMC/A32NX_FMCMainDisplay.js @@ -4868,6 +4868,10 @@ class FMCMainDisplay extends BaseAirliners { return this.thrustReductionAltitude; } + getOriginTransitionAltitude() { + return this.flightPlanManager.getOriginTransitionAltitude(); + } + getCruiseAltitude() { return this.cruiseFlightLevel * 100; } diff --git a/src/fmgc/src/guidance/GuidanceController.ts b/src/fmgc/src/guidance/GuidanceController.ts index c6e44db680bf..3d42e5cb4e97 100644 --- a/src/fmgc/src/guidance/GuidanceController.ts +++ b/src/fmgc/src/guidance/GuidanceController.ts @@ -38,6 +38,7 @@ export interface Fmgc { getManagedClimbSpeedMach(): Mach; getAccelerationAltitude(): Feet, getThrustReductionAltitude(): Feet, + getOriginTransitionAltitude(): Feet | undefined, getCruiseAltitude(): Feet, getFlightPhase(): FmgcFlightPhase, getManagedCruiseSpeed(): Knots, diff --git a/src/fmgc/src/guidance/vnav/VerticalProfileComputationParameters.ts b/src/fmgc/src/guidance/vnav/VerticalProfileComputationParameters.ts index 11221014f06c..595f57f0bbcc 100644 --- a/src/fmgc/src/guidance/vnav/VerticalProfileComputationParameters.ts +++ b/src/fmgc/src/guidance/vnav/VerticalProfileComputationParameters.ts @@ -32,6 +32,7 @@ export interface VerticalProfileComputationParameters { destinationAirfieldElevation: Feet, accelerationAltitude: Feet, thrustReductionAltitude: Feet, + originTransitionAltitude?: Feet, cruiseAltitude: Feet, climbSpeedLimit: SpeedLimit, descentSpeedLimit: SpeedLimit, @@ -83,6 +84,7 @@ export class VerticalProfileComputationParametersObserver { destinationAirfieldElevation: SimVar.GetSimVarValue('L:A32NX_PRESS_AUTO_LANDING_ELEVATION', 'feet'), accelerationAltitude: this.fmgc.getAccelerationAltitude(), thrustReductionAltitude: this.fmgc.getThrustReductionAltitude(), + originTransitionAltitude: this.fmgc.getOriginTransitionAltitude(), cruiseAltitude: Number.isFinite(this.fmgc.getCruiseAltitude()) ? this.fmgc.getCruiseAltitude() : this.parameters.cruiseAltitude, climbSpeedLimit: this.fmgc.getClimbSpeedLimit(), descentSpeedLimit: this.fmgc.getDescentSpeedLimit(), diff --git a/src/fmgc/src/guidance/vnav/VnavDriver.ts b/src/fmgc/src/guidance/vnav/VnavDriver.ts index c8968155d644..a584a356efc9 100644 --- a/src/fmgc/src/guidance/vnav/VnavDriver.ts +++ b/src/fmgc/src/guidance/vnav/VnavDriver.ts @@ -173,7 +173,7 @@ export class VnavDriver implements GuidanceComponent { } this.updateTimeMarkers(); - this.descentGuidance.update(); + this.descentGuidance.update(deltaTime); } catch (e) { console.error('[FMS] Failed to calculate vertical profil. See exception below.'); console.error(e); diff --git a/src/fmgc/src/guidance/vnav/descent/AircraftToProfileRelation.ts b/src/fmgc/src/guidance/vnav/descent/AircraftToProfileRelation.ts index 6c20d0af411d..e1de61af8ccf 100644 --- a/src/fmgc/src/guidance/vnav/descent/AircraftToProfileRelation.ts +++ b/src/fmgc/src/guidance/vnav/descent/AircraftToProfileRelation.ts @@ -68,6 +68,13 @@ export class AircraftToDescentProfileRelation { return !this.topOfDescent || this.inertialDistanceAlongTrack.get() > this.topOfDescent.distanceFromStart; } + distanceToTopOfDescent(): number|null { + if (this.topOfDescent) { + return this.topOfDescent.distanceFromStart - this.inertialDistanceAlongTrack.get(); + } + return null; + } + isOnGeometricPath(): boolean { return this.inertialDistanceAlongTrack.get() > this.geometricPathStart.distanceFromStart; } diff --git a/src/fmgc/src/guidance/vnav/descent/DescentGuidance.ts b/src/fmgc/src/guidance/vnav/descent/DescentGuidance.ts index f24d8f98aa12..5d8be8f5f444 100644 --- a/src/fmgc/src/guidance/vnav/descent/DescentGuidance.ts +++ b/src/fmgc/src/guidance/vnav/descent/DescentGuidance.ts @@ -7,6 +7,7 @@ import { VerticalMode } from '@shared/autopilot'; import { FmgcFlightPhase } from '@shared/flightphase'; import { VnavConfig } from '@fmgc/guidance/vnav/VnavConfig'; import { SpeedMargin } from './SpeedMargin'; +import { TodGuidance } from './TodGuidance'; enum DescentVerticalGuidanceState { InvalidProfile, @@ -41,9 +42,9 @@ export class DescentGuidance { private speedMargin: SpeedMargin; - private speedTarget: Knots | Mach; + private todGuidance: TodGuidance; - private tdReached: boolean; + private speedTarget: Knots | Mach; // An "overspeed condition" just means we are above the speed margins, not that we are in the red band. // We use a boolean here for hysteresis @@ -57,6 +58,7 @@ export class DescentGuidance { private atmosphericConditions: AtmosphericConditions, ) { this.speedMargin = new SpeedMargin(this.observer); + this.todGuidance = new TodGuidance(this.aircraftToDescentProfileRelation, this.observer, this.atmosphericConditions); this.writeToSimVars(); } @@ -91,7 +93,7 @@ export class DescentGuidance { this.isInOverspeedCondition = false; } - update() { + update(deltaTime: number) { this.aircraftToDescentProfileRelation.update(); if (!this.aircraftToDescentProfileRelation.isValid) { @@ -113,19 +115,7 @@ export class DescentGuidance { } this.writeToSimVars(); - this.updateTdReached(); - } - - updateTdReached() { - const { flightPhase } = this.observer.get(); - const isPastTopOfDescent = this.aircraftToDescentProfileRelation.isPastTopOfDescent(); - const isInManagedSpeed = Simplane.getAutoPilotAirspeedManaged(); - - const tdReached = flightPhase >= FmgcFlightPhase.Climb && flightPhase <= FmgcFlightPhase.Cruise && isPastTopOfDescent && isInManagedSpeed; - if (tdReached !== this.tdReached) { - this.tdReached = tdReached; - SimVar.SetSimVarValue('L:A32NX_PFD_MSG_TD_REACHED', 'boolean', this.tdReached); - } + this.todGuidance.update(deltaTime); } private updateLinearDeviation() { diff --git a/src/fmgc/src/guidance/vnav/descent/LatchedDescentGuidance.ts b/src/fmgc/src/guidance/vnav/descent/LatchedDescentGuidance.ts index c87c4759b59c..eca5b063abf0 100644 --- a/src/fmgc/src/guidance/vnav/descent/LatchedDescentGuidance.ts +++ b/src/fmgc/src/guidance/vnav/descent/LatchedDescentGuidance.ts @@ -5,6 +5,7 @@ import { NavGeometryProfile } from '@fmgc/guidance/vnav/profile/NavGeometryProfi import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters'; import { VerticalMode } from '@shared/autopilot'; import { FmgcFlightPhase } from '@shared/flightphase'; +import { TodGuidance } from './TodGuidance'; import { SpeedMargin } from './SpeedMargin'; enum DescentVerticalGuidanceState { @@ -38,9 +39,9 @@ export class LatchedDescentGuidance { private speedMargin: SpeedMargin; - private speedTarget: Knots | Mach; + private todGuidance: TodGuidance; - private tdReached: boolean; + private speedTarget: Knots | Mach; // An "overspeed condition" just means we are above the speed margins, not that we are in the red band. // We use a boolean here for hysteresis @@ -52,6 +53,7 @@ export class LatchedDescentGuidance { private atmosphericConditions: AtmosphericConditions, ) { this.speedMargin = new SpeedMargin(this.observer); + this.todGuidance = new TodGuidance(this.aircraftToDescentProfileRelation, this.observer, this.atmosphericConditions); this.writeToSimVars(); } @@ -86,7 +88,7 @@ export class LatchedDescentGuidance { this.isInOverspeedCondition = false; } - update() { + update(deltaTime: number) { this.aircraftToDescentProfileRelation.update(); if (!this.aircraftToDescentProfileRelation.isValid) { @@ -108,19 +110,7 @@ export class LatchedDescentGuidance { } this.writeToSimVars(); - this.updateTdReached(); - } - - updateTdReached() { - const { flightPhase } = this.observer.get(); - const isPastTopOfDescent = this.aircraftToDescentProfileRelation.isPastTopOfDescent(); - const isInManagedSpeed = Simplane.getAutoPilotAirspeedManaged(); - - const tdReached = flightPhase <= FmgcFlightPhase.Cruise && isPastTopOfDescent && isInManagedSpeed; - if (tdReached !== this.tdReached) { - this.tdReached = tdReached; - SimVar.SetSimVarValue('L:A32NX_PFD_MSG_TD_REACHED', 'boolean', this.tdReached); - } + this.todGuidance.update(deltaTime); } private updateLinearDeviation() { diff --git a/src/fmgc/src/guidance/vnav/descent/TodGuidance.ts b/src/fmgc/src/guidance/vnav/descent/TodGuidance.ts new file mode 100644 index 000000000000..061ec7fb8ab0 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/descent/TodGuidance.ts @@ -0,0 +1,107 @@ +import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions'; +import { AircraftToDescentProfileRelation } from '@fmgc/guidance/vnav/descent/AircraftToProfileRelation'; +import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters'; +import { LateralMode } from '@shared/autopilot'; +import { FmgcFlightPhase } from '@shared/flightphase'; +import { NXDataStore } from '@shared/persistence'; +import { PopUp } from '@shared/popup'; + +const TIMEOUT = 10_000; + +export class TodGuidance { + private tdReached: boolean; + + private tdPaused: boolean; + + private apEngaged: boolean; + + private cooldown: number; + + constructor( + private aircraftToDescentProfileRelation: AircraftToDescentProfileRelation, + private observer: VerticalProfileComputationParametersObserver, + private atmosphericConditions: AtmosphericConditions, + ) { + this.cooldown = 0; + this.apEngaged = false; + this.tdReached = false; + this.tdPaused = false; + } + + showPausePopup(title: string, message: string) { + this.cooldown = TIMEOUT; + SimVar.SetSimVarValue('K:PAUSE_SET', 'number', 1); + let popup = new PopUp(); + popup.showInformation(title, message, 'small', + () => { + SimVar.SetSimVarValue('K:PAUSE_SET', 'number', 0); + this.cooldown = TIMEOUT; + popup = null; + }); + } + + update(deltaTime: number) { + this.updateTdReached(deltaTime); + this.updateTdPause(deltaTime); + } + + updateTdPause(deltaTime: number) { + if ( + this.cooldown <= 0 + && NXDataStore.get('PAUSE_AT_TOD', 'DISABLED') === 'ENABLED' + ) { + // Only watching if T/D pause untriggered + between flight phase CLB and CRZ + if (!this.tdPaused + && this.observer.get().flightPhase >= FmgcFlightPhase.Climb + && this.observer.get().flightPhase <= FmgcFlightPhase.Cruise + && Simplane.getAutoPilotAirspeedManaged() + ) { + // Check T/D pause first, then AP mode reversion + if ((this.aircraftToDescentProfileRelation.distanceToTopOfDescent() ?? Number.POSITIVE_INFINITY) < parseFloat(NXDataStore.get('PAUSE_AT_TOD_DISTANCE', '10'))) { + this.tdPaused = true; + this.showPausePopup( + 'TOP OF DESCENT', + `Paused before the calculated top of descent. System Time was ${new Date().toLocaleTimeString()}.`, + ); + // Only guard AP above transitional altitude + } else if (this.atmosphericConditions.currentAltitude ? this.atmosphericConditions.currentAltitude > this.observer.get().originTransitionAltitude : false) { + const apActive = SimVar.GetSimVarValue('L:A32NX_AUTOPILOT_ACTIVE', 'boolean') && SimVar.GetSimVarValue('L:A32NX_FMA_LATERAL_MODE', 'Enum') === LateralMode.NAV; + + if (this.apEngaged && !apActive) { + this.showPausePopup( + 'AP PROTECTION', + `Autopilot or lateral guindace disengaged before the calculated top of descent. System Time was ${new Date().toLocaleTimeString()}.`, + ); + } + + if (this.apEngaged !== apActive) { + this.apEngaged = apActive; + } + } + } + + // Reset flags on turnaround + if (this.observer.get().flightPhase === FmgcFlightPhase.Done || this.observer.get().flightPhase === FmgcFlightPhase.Preflight) { + this.tdPaused = false; + this.apEngaged = false; + } + } + + if (this.cooldown > 0) { + this.cooldown = Math.max(0, this.cooldown - deltaTime); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + updateTdReached(deltaTime: number) { + const tdReached = this.observer.get().flightPhase >= FmgcFlightPhase.Climb + && this.observer.get().flightPhase <= FmgcFlightPhase.Cruise + && Simplane.getAutoPilotAirspeedManaged() + && this.aircraftToDescentProfileRelation.isPastTopOfDescent(); + + if (tdReached !== this.tdReached) { + this.tdReached = tdReached; + SimVar.SetSimVarValue('L:A32NX_PFD_MSG_TD_REACHED', 'boolean', this.tdReached); + } + } +}