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);
+ }
+ }
+}