diff --git a/docs/a320-simvars.md b/docs/a320-simvars.md
index 2973184de88e..5305ac8090bf 100644
--- a/docs/a320-simvars.md
+++ b/docs/a320-simvars.md
@@ -1264,6 +1264,35 @@
- Bool
- Indicates if the SET HOLD SPEED message is shown on the PFD
+- A32NX_PFD_MSG_TD_REACHED
+ - Bool
+ - Indicates if the T/D REACHED message is shown on the PFD
+
+- A32NX_PFD_LINEAR_DEVIATION_ACTIVE
+ - Bool
+ - Indicates if the linear deviation is shown on the PFD
+
+- A32NX_PFD_TARGET_ALTITUDE
+ - Feet
+ - Indicates the current target altitude in the DES mode. This is an indicated altitude and not a pressure altitude
+ - This is used to compute a linear deviation
+
+- A32NX_PFD_VERTICAL_PROFILE_LATCHED
+ - Boolean
+ - Indicates whether to show the latch symbol on the PFD with the deviation indicator
+
+- L:A32NX_PFD_SHOW_SPEED_MARGINS
+ - Boolean
+ - Indicates whether speed margins are shown on the PFD in DES mode.
+
+- L:A32NX_PFD_UPPER_SPEED_MARGIN
+ - Knots
+ - Indicates the speed for the upper speed margin limit in DES mode
+
+- L:A32NX_PFD_LOWER_SPEED_MARGIN
+ - Knots
+ - Indicates the speed for the lower speed margin limit in DES mode
+
- A32NX_ISIS_LS_ACTIVE
- Bool
- Indicates whether LS scales are shown on the ISIS
@@ -1669,6 +1698,16 @@ In the variables below, {number} should be replaced with one item in the set: {
- Indicates whether the FMS should switch to APPROACH phase.
- **WARNING:** This is temporary and internal. Do not use.
+- A32NX_FM_VNAV_DEBUG_POINT
+ - Nautical miles
+ - Indicates the distance from start at which to draw a debug pseudowaypoint on the ND
+ - **WARNING:** This is only used for testing purposes.
+
+- A32NX_FM_VNAV_DEBUG_SPEED_BIAS
+ - Knots
+ - This is indicative of how aggressively the speed margins should be used while above the profile
+ - **WARNING:** This is only used for testing purposes.
+
## Autopilot System
- A32NX_FMA_LATERAL_MODE
diff --git a/flybywire-aircraft-a320-neo/html_ui/Pages/A32NX_Utils/NXSystemMessages.js b/flybywire-aircraft-a320-neo/html_ui/Pages/A32NX_Utils/NXSystemMessages.js
index 2c66e0ba2c21..9def2cafd8fb 100644
--- a/flybywire-aircraft-a320-neo/html_ui/Pages/A32NX_Utils/NXSystemMessages.js
+++ b/flybywire-aircraft-a320-neo/html_ui/Pages/A32NX_Utils/NXSystemMessages.js
@@ -89,7 +89,8 @@ const NXSystemMessages = {
noAtisReceived: new TypeIMessage("NO ATIS REPORT RECEIVED"),
noPreviousAtis: new TypeIMessage("NO PREVIOUS ATIS STORED"),
arptTypeAlreadyInUse: new TypeIMessage("ARPT/TYPE ALREADY USED"),
- cancelAtisUpdate: new TypeIMessage("CANCEL UPDATE BEFORE")
+ cancelAtisUpdate: new TypeIMessage("CANCEL UPDATE BEFORE"),
+ stepAboveMaxFl: new TypeIMessage("STEP ABOVE MAX FL"),
};
const NXFictionalMessages = {
diff --git a/flybywire-aircraft-a320-neo/html_ui/Pages/A32NX_Utils/NXUnits.js b/flybywire-aircraft-a320-neo/html_ui/Pages/A32NX_Utils/NXUnits.js
index dd636df7d916..5826dc068164 100644
--- a/flybywire-aircraft-a320-neo/html_ui/Pages/A32NX_Utils/NXUnits.js
+++ b/flybywire-aircraft-a320-neo/html_ui/Pages/A32NX_Utils/NXUnits.js
@@ -22,6 +22,10 @@ class NXUnits {
return NXUnits.metricWeight ? value : value * 2.204625;
}
+ static poundsToUser(value) {
+ return NXUnits.metricWeight ? value / 2.204625 : value;
+ }
+
static userWeightUnit() {
return NXUnits.metricWeight ? 'KG' : 'LBS'; // EIS uses S suffix on LB
}
diff --git a/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU.html b/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU.html
index 57a51f03d802..d427f03ad118 100644
--- a/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU.html
+++ b/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU.html
@@ -142,6 +142,7 @@
+
diff --git a/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_FlightPlanPage.js b/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_FlightPlanPage.js
index b2de7e303650..c3da2bda656f 100644
--- a/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_FlightPlanPage.js
+++ b/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_FlightPlanPage.js
@@ -32,6 +32,14 @@ class CDUFlightPlanPage {
return [runwayText, runwayAlt];
}
+ function formatAltitudeOrLevel(altitudeToFormat) {
+ if (mcdu.flightPlanManager.getOriginTransitionAltitude() >= 100 && altitudeToFormat > mcdu.flightPlanManager.getOriginTransitionAltitude()) {
+ return `FL${(altitudeToFormat / 100).toFixed(0).toString().padStart(3,"0")}`;
+ }
+
+ return (10 * Math.round(altitudeToFormat / 10)).toFixed(0).toString().padStart(5,"\xa0");
+ }
+
//mcdu.flightPlanManager.updateWaypointDistances(false /* approach */);
//mcdu.flightPlanManager.updateWaypointDistances(true /* approach */);
mcdu.clearDisplay();
@@ -84,10 +92,25 @@ class CDUFlightPlanPage {
// PWPs
const fmsPseudoWaypoints = mcdu.guidanceController.currentPseudoWaypoints;
+ const fmsGeometryProfile = mcdu.guidanceController.vnavDriver.currentNavGeometryProfile;
+
+ let vnavPredictionsMapByWaypoint = null;
+ if (fmsGeometryProfile && fmsGeometryProfile.isReadyToDisplay) {
+ vnavPredictionsMapByWaypoint = fmsGeometryProfile.waypointPredictions;
+ }
+ let cumulativeDistance = 0;
// Primary F-PLAN
+
+ // In this loop, we insert pseudowaypoints between regular waypoints and compute the distances between the previous and next (pseudo-)waypoint.
for (let i = first; i < fpm.getWaypointsCount(); i++) {
const pseudoWaypointsOnLeg = fmsPseudoWaypoints.filter((it) => it.displayedOnMcdu && it.alongLegIndex === i);
+ pseudoWaypointsOnLeg.sort((a, b) => a.flightPlanInfo.distanceFromLastFix - b.flightPlanInfo.distanceFromLastFix);
+
+ for (const pwp of pseudoWaypointsOnLeg) {
+ pwp.distanceInFP = pwp.distanceFromStart - cumulativeDistance;
+ cumulativeDistance = pwp.distanceFromStart;
+ }
if (pseudoWaypointsOnLeg) {
waypointsAndMarkers.push(...pseudoWaypointsOnLeg.map((pwp) => ({ pwp, fpIndex: i })));
@@ -95,6 +118,15 @@ class CDUFlightPlanPage {
const wp = fpm.getWaypoint(i);
+ // We either use the VNAV distance (which takes transitions into account), or we use whatever has already been computed in wp.distanceInFP.
+ if (vnavPredictionsMapByWaypoint && vnavPredictionsMapByWaypoint.get(i)) {
+ wp.distanceFromLastLine = vnavPredictionsMapByWaypoint.get(i).distanceFromStart - cumulativeDistance;
+ cumulativeDistance = vnavPredictionsMapByWaypoint.get(i).distanceFromStart;
+ } else {
+ wp.distanceFromLastLine = wp.distanceInFP;
+ cumulativeDistance = wp.cumulativeDistanceInFP;
+ }
+
if (i >= fpm.getActiveWaypointIndex() && wp.additionalData.legType === 14 /* HM */) {
waypointsAndMarkers.push({ holdResumeExit: wp, fpIndex: i });
}
@@ -175,21 +207,9 @@ class CDUFlightPlanPage {
let ident = wp.ident;
const isOverfly = wp.additionalData && wp.additionalData.overfly;
- // Time
- let time;
- let timeCell = "----[s-text]";
- if (ident !== "MANUAL") {
- if (isFlying) {
- if (fpIndex === fpm.getDestinationIndex() || isFinite(wp.liveUTCTo) || isFinite(wp.waypointReachedAt)) {
- time = (fpIndex === fpm.getDestinationIndex() || wpActive || ident === "(DECEL)") ? stats.get(fpIndex).etaFromPpos : wp.waypointReachedAt;
- timeCell = `${FMCMainDisplay.secondsToUTC(time)}[s-text]`;
- }
- } else {
- if (fpIndex === fpm.getDestinationIndex() || isFinite(wp.liveETATo)) {
- time = (fpIndex === fpm.getDestinationIndex() || wpActive || ident === "(DECEL)") ? stats.get(fpIndex).timeFromPpos : 0;
- timeCell = `${FMCMainDisplay.secondsTohhmm(time)}[s-text]`;
- }
- }
+ let verticalWaypoint = null;
+ if (vnavPredictionsMapByWaypoint) {
+ verticalWaypoint = vnavPredictionsMapByWaypoint.get(fpIndex);
}
// Color
@@ -200,6 +220,19 @@ class CDUFlightPlanPage {
color = "white";
}
+ // Time
+ let timeCell = "----[s-text]";
+ let timeColor = "white";
+ if (verticalWaypoint && isFinite(verticalWaypoint.secondsFromPresent)) {
+ const utcTime = SimVar.GetGlobalVarValue("ZULU TIME", "seconds");
+
+ timeCell = isFlying
+ ? `${FMCMainDisplay.secondsToUTC(utcTime + verticalWaypoint.secondsFromPresent)}[s-text]`
+ : `${FMCMainDisplay.secondsTohhmm(verticalWaypoint.secondsFromPresent)}[s-text]`;
+
+ timeColor = color;
+ }
+
// Fix Header
let fixAnnotation = wp.additionalData.annotation;
@@ -263,21 +296,29 @@ class CDUFlightPlanPage {
if (fpIndex === fpm.getActiveWaypointIndex()) {
distance = stats.get(fpIndex).distanceFromPpos.toFixed(0);
} else {
- distance = stats.get(fpIndex).distanceInFP.toFixed(0);
+ distance = wp.distanceFromLastLine.toFixed(0);
}
if (distance > 9999) {
distance = 9999;
}
distance = distance.toString();
+ let altColor = color;
+ let spdColor = color;
+ let slashColor = color;
+
let speedConstraint = "---";
- if (wp.speedConstraint > 10 && ident !== "MANUAL") {
- speedConstraint = `{magenta}*{end}${wp.speedConstraint.toFixed(0)}`;
+ let speedPrefix = "";
+
+ if (verticalWaypoint && verticalWaypoint.speed) {
+ speedConstraint = verticalWaypoint.speed < 1 ? formatMachNumber(verticalWaypoint.speed) : Math.round(verticalWaypoint.speed);
+
+ if (wp.speedConstraint > 10) {
+ speedPrefix = verticalWaypoint.isSpeedConstraintMet ? "{magenta}*{end}" : "{amber}*{end}";
+ }
}
- let altColor = color;
- let spdColor = color;
- let timeColor = color;
+ speedConstraint = speedPrefix + speedConstraint;
// Altitude
const hasAltConstraint = wp.legAltitudeDescription > 0 && wp.legAltitudeDescription < 6;
@@ -304,88 +345,30 @@ class CDUFlightPlanPage {
altColor = color;
}
altitudeConstraint = altitudeConstraint.padStart(5,"\xa0");
- } else if (ident !== "MANUAL") {
- const firstRouteIndex = 1 + fpm.getDepartureWaypointsCount();
- const lastRouteIndex = fpm.getLastIndexBeforeApproach();
- //const departureWp = firstRouteIndex > 1 && fpm.getDepartureWaypoints().indexOf(wp) !== -1;
-
- // TODO all this constraint code is very sussy
-
- if (hasAltConstraint) {
- if (mcdu.flightPlanManager.getOriginTransitionAltitude() >= 100 && wp.legAltitude1 > mcdu.flightPlanManager.getOriginTransitionAltitude()) {
- altitudeConstraint = (wp.legAltitude1 / 100).toFixed(0).toString();
- altitudeConstraint = `FL${altitudeConstraint.padStart(3, "0")}`;
- } else {
- altitudeConstraint = wp.legAltitude1.toFixed(0).toString().padStart(5, "\xa0");
- }
- }
+ } else {
+ let altitudeToFormat = wp.legAltitude1;
if (hasAltConstraint && ident !== "(DECEL)") {
- altPrefix = "{magenta}*{end}";
- if (wp.legAltitudeDescription === 4) {
- altitudeConstraint = ((wp.legAltitude1 + wp.legAltitude2) * 0.5).toFixed(0).toString();
- altitudeConstraint = altitudeConstraint.padStart(5,"\xa0");
+ if (verticalWaypoint && verticalWaypoint.altitude) {
+ altitudeToFormat = verticalWaypoint.altitude;
}
- // TODO FIXME: remove this and replace with proper altitude constraint implementation
- // Predict altitude for STAR when constraints are missing
- /*
- } else if (departureWp) {
- altitudeConstraint = Math.floor(wp.cumulativeDistanceInFP * 0.14 * 6076.118 / 10).toString();
- altitudeConstraint = altitudeConstraint.padStart(5,"\xa0");
- // Waypoint is the first or the last of the actual route
- */
- } else if ((fpIndex === firstRouteIndex - 1) || (fpIndex === lastRouteIndex + 1)) {
- if (Object.is(NaN, mcdu.cruiseFlightLevel)) {
- altitudeConstraint = "-----";
+
+ altitudeConstraint = formatAltitudeOrLevel(altitudeToFormat);
+
+ if (verticalWaypoint) {
+ altPrefix = verticalWaypoint.isAltitudeConstraintMet ? "{magenta}*{end}" : "{amber}*{end}";
} else {
- altitudeConstraint = `FL${mcdu.cruiseFlightLevel.toString().padStart(3,"0")}`;
+ altColor = "magenta";
+ slashColor = "magenta";
}
- if (fpIndex !== -42) {
- mcdu.leftInputDelay[rowI] = (value) => {
- if (value === "") {
- if (waypoint) {
- return mcdu.getDelaySwitchPage();
- }
- }
- return mcdu.getDelayBasic();
- };
- mcdu.onLeftInput[rowI] = async (value, scratchpadCallback) => {
- if (value === "") {
- if (waypoint) {
- CDULateralRevisionPage.ShowPage(mcdu, waypoint, fpIndex);
- }
- } else if (value === FMCMainDisplay.clrValue) {
- mcdu.removeWaypoint(fpIndex, () => {
- CDUFlightPlanPage.ShowPage(mcdu, offset);
- });
- } else if (value.length > 0) {
- mcdu.insertWaypoint(value, fpIndex, (success) => {
- if (!success) {
- scratchpadCallback();
- }
- CDUFlightPlanPage.ShowPage(mcdu, offset);
- });
- }
- };
- mcdu.rightInputDelay[rowI] = () => {
- return mcdu.getDelaySwitchPage();
- };
- mcdu.onRightInput[rowI] = async () => {
- if (waypoint) {
- CDUVerticalRevisionPage.ShowPage(mcdu, waypoint);
- }
- };
- }
- // Waypoint is in between on the route
- } else if (fpIndex <= lastRouteIndex && fpIndex >= firstRouteIndex) {
- if (Object.is(NaN, mcdu.cruiseFlightLevel)) {
- altitudeConstraint = "-----";
+ // Waypoint with no alt constraint.
+ // In this case `altitudeConstraint is actually just the predictedAltitude`
+ } else if (vnavPredictionsMapByWaypoint && !hasAltConstraint) {
+ if (verticalWaypoint && verticalWaypoint.altitude) {
+ altitudeConstraint = formatAltitudeOrLevel(verticalWaypoint.altitude);
} else {
- altitudeConstraint = `FL${mcdu.cruiseFlightLevel.toString().padStart(3,"0")}`;
+ altitudeConstraint = "-----";
}
- // Waypoint with no alt constraint
- } else if (!wp.legAltitude1 && !wp.legAltitudeDescription) {
- altitudeConstraint = "-----";
}
}
@@ -397,7 +380,7 @@ class CDUFlightPlanPage {
altColor = "white";
}
- if (fpIndex !== fpm.getDestinationIndex()) {
+ if (fpIndex !== fpm.getDestinationIndex() && timeCell !== "----[s-text]") {
timeColor = color;
} else {
timeColor = "white";
@@ -415,18 +398,19 @@ class CDUFlightPlanPage {
scrollWindow[rowI] = {
fpIndex: fpIndex,
active: wpActive,
- ident: ident,
- color: color,
- distance: distance,
- spdColor: spdColor,
- speedConstraint: speedConstraint,
- altColor: altColor,
+ ident,
+ color,
+ distance,
+ spdColor,
+ speedConstraint,
+ altColor,
altitudeConstraint: { alt: altitudeConstraint, altPrefix: altPrefix },
- timeCell: timeCell,
- timeColor: timeColor,
+ timeCell,
+ timeColor,
fixAnnotation: fixAnnotation ? fixAnnotation : "",
- bearingTrack: bearingTrack,
- isOverfly: isOverfly,
+ bearingTrack,
+ isOverfly,
+ slashColor
};
if (fpIndex !== fpm.getDestinationIndex()) {
@@ -484,10 +468,17 @@ class CDUFlightPlanPage {
});
}
+ const flightPhaseIsBeyondDescent = mcdu.flightPhaseManager.phase >= FmgcFlightPhases.DESCENT;
+
+ let isConsideredDescentWaypoint = flightPhaseIsBeyondDescent;
+ if (verticalWaypoint) {
+ isConsideredDescentWaypoint = isConsideredDescentWaypoint || verticalWaypoint.distanceToTopOfDescent < 0;
+ }
+
addRskAt(rowI, () => mcdu.getDelaySwitchPage(),
(value, scratchpadCallback) => {
if (value === "") {
- CDUVerticalRevisionPage.ShowPage(mcdu, wp);
+ CDUVerticalRevisionPage.ShowPage(mcdu, wp, verticalWaypoint, isConsideredDescentWaypoint);
} else if (value === FMCMainDisplay.clrValue) {
mcdu.setScratchpadMessage(NXSystemMessages.notAllowed);
} else {
@@ -496,22 +487,46 @@ class CDUFlightPlanPage {
});
} else if (pwp) {
+ const color = (fpm.isCurrentFlightPlanTemporary()) ? "yellow" : "green";
+
+ let timeCell = "----[s-text]";
+ if (pwp.flightPlanInfo && isFinite(pwp.flightPlanInfo.secondsFromPresent)) {
+ const utcTime = SimVar.GetGlobalVarValue("ZULU TIME", "seconds");
+
+ timeCell = isFlying
+ ? `${FMCMainDisplay.secondsToUTC(utcTime + pwp.flightPlanInfo.secondsFromPresent)}[s-text]`
+ : `${FMCMainDisplay.secondsTohhmm(pwp.flightPlanInfo.secondsFromPresent)}[s-text]`;
+ }
+
+ let speed = "---";
+ if (pwp.flightPlanInfo) {
+ speed = pwp.flightPlanInfo.speed < 1 ? formatMachNumber(pwp.flightPlanInfo.speed) : Math.round(pwp.flightPlanInfo.speed).toFixed(0);
+ }
+
scrollWindow[rowI] = {
fpIndex: fpIndex,
active: false,
- ident: pwp.ident,
+ ident: pwp.mcduIdent || pwp.ident,
color: (fpm.isCurrentFlightPlanTemporary()) ? "yellow" : "green",
- distance: Math.round(pwp.stats.distanceInFP).toString(),
- spdColor: "white",
- speedConstraint: "---",
- altColor: 'white',
- altitudeConstraint: { alt: "-----", altPrefix: "\xa0" },
- timeCell: "----[s-text]",
- timeColor: "white",
- fixAnnotation: "",
+ distance: pwp.distanceInFP ? Math.round(pwp.distanceInFP).toFixed(0) : "",
+ spdColor: pwp.flightPlanInfo ? "green" : "white",
+ speedConstraint: speed,
+ altColor: pwp.flightPlanInfo ? "green" : "white",
+ altitudeConstraint: { alt: pwp.flightPlanInfo ? formatAltitudeOrLevel(pwp.flightPlanInfo.altitude) : "-----", altPrefix: "\xa0" },
+ timeCell,
+ timeColor: color,
+ fixAnnotation: `{green}${pwp.mcduHeader || ''}{end}`,
bearingTrack: "",
isOverfly: false,
+ slashColor: "green"
};
+
+ addLskAt(rowI, 0, (value, scratchpadCallback) => {
+ if (value === FMCMainDisplay.clrValue) {
+ // TODO
+ mcdu.addNewMessage(NXSystemMessages.notAllowed);
+ }
+ });
} else if (marker) {
// Marker
@@ -680,21 +695,26 @@ class CDUFlightPlanPage {
let destDistCell = "---";
let destEFOBCell = "---";
- if (fpm.getDestination()) {
+ if (fpm.getDestination() && fmsGeometryProfile) {
const destStats = stats.get(fpm.getCurrentFlightPlan().waypoints.length - 1);
if (destStats) {
destDistCell = destStats.distanceFromPpos.toFixed(0);
- destEFOBCell = (NXUnits.kgToUser(mcdu.getDestEFOB(isFlying))).toFixed(1);
- if (isFlying) {
- destTimeCell = FMCMainDisplay.secondsToUTC(destStats.etaFromPpos);
- } else {
- destTimeCell = FMCMainDisplay.secondsTohhmm(destStats.timeFromPpos);
+
+ const destEfob = fmsGeometryProfile.getRemainingFuelAtDestination();
+ if (isFinite(destEfob)) {
+ destEFOBCell = (NXUnits.poundsToUser(destEfob) / 1000).toFixed(1);
+ }
+
+ const timeRemaining = fmsGeometryProfile.getTimeToDestination();
+ if (isFinite(timeRemaining)) {
+ const utcTime = SimVar.GetGlobalVarValue("ZULU TIME", "seconds");
+
+ destTimeCell = isFlying
+ ? FMCMainDisplay.secondsToUTC(utcTime + timeRemaining)
+ : FMCMainDisplay.secondsTohhmm(timeRemaining);
}
}
}
- if (!CDUInitPage.fuelPredConditionsMet(mcdu)) {
- destEFOBCell = "---";
- }
destText[0] = ["\xa0DEST", "DIST EFOB", isFlying ? "\xa0UTC{sp}{sp}{sp}{sp}" : "TIME{sp}{sp}{sp}{sp}"];
destText[1] = [destCell, `{small}${destDistCell}\xa0${destEFOBCell.padStart(4, '\xa0')}{end}`, `{small}${destTimeCell}{end}{sp}{sp}{sp}{sp}`];
@@ -790,11 +810,11 @@ function renderFixHeader(rowObj, showNm = false, showDist = true, showFix = true
}
function renderFixContent(rowObj, spdRepeat = false, altRepeat = false) {
- const {ident, isOverfly, color, spdColor, speedConstraint, altColor, altitudeConstraint, timeCell, timeColor} = rowObj;
+ const {ident, isOverfly, color, spdColor, speedConstraint, altColor, altitudeConstraint, timeCell, timeColor, slashColor} = rowObj;
return [
`${ident}${isOverfly ? FMCMainDisplay.ovfyValue : ""}[color]${color}`,
- `{${spdColor}}${spdRepeat ? "\xa0\"\xa0" : speedConstraint}{end}{${altColor}}/${altRepeat ? "\xa0\xa0\xa0\"\xa0\xa0" : altitudeConstraint.altPrefix + altitudeConstraint.alt}{end}[s-text]`,
+ `{${spdColor}}${spdRepeat ? "\xa0\"\xa0" : speedConstraint}{end}{${altColor}}{${slashColor}}/{end}${altRepeat ? "\xa0\xa0\xa0\"\xa0\xa0" : altitudeConstraint.altPrefix + altitudeConstraint.alt}{end}[s-text]`,
`${timeCell}{sp}{sp}{sp}{sp}[color]${timeColor}`
];
}
@@ -832,3 +852,7 @@ function legTurnIsForced(wp) {
// left || right
return wp.turnDirection === 1 || wp.turnDirection === 2;
}
+
+function formatMachNumber(rawNumber) {
+ return (Math.round(100 * rawNumber) / 100).toFixed(2).slice(1);
+}
diff --git a/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_PerformancePage.js b/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_PerformancePage.js
index 85b5fba32235..6a7027de074c 100644
--- a/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_PerformancePage.js
+++ b/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_PerformancePage.js
@@ -428,6 +428,31 @@ class CDUPerformancePage {
const isSelected = Simplane.getAutoPilotAirspeedSelected();
const actModeCell = isSelected ? "SELECTED" : "MANAGED";
const costIndexCell = isFinite(mcdu.costIndex) ? mcdu.costIndex.toFixed(0) + "[color]cyan" : "[][color]cyan";
+
+ // Predictions to altitude
+ const vnavDriver = mcdu.guidanceController.vnavDriver;
+
+ const cruiseAltitude = mcdu.cruiseFlightLevel * 100;
+ const fcuAltitude = SimVar.GetSimVarValue("AUTOPILOT ALTITUDE LOCK VAR:3", "feet");
+ const altitudeToPredict = Math.min(cruiseAltitude, fcuAltitude);
+
+ const predToCell = CDUPerformancePage.formatAltitudeOrLevel(altitudeToPredict, mcdu.flightPlanManager.getOriginTransitionAltitude()) + "[color]cyan";
+
+ let predToDistanceCell = "---";
+ let predToTimeCell = "----";
+
+ let expeditePredToDistanceCell = "---";
+ let expeditePredToTimeCell = "----";
+
+ if (vnavDriver) {
+ [predToDistanceCell, predToTimeCell] = CDUPerformancePage.getTimeAndDistancePredictionsFromGeometryProfile(vnavDriver.currentNavGeometryProfile, altitudeToPredict, mcdu.flightPhaseManager.phase >= FmgcFlightPhases.TAKEOFF);
+
+ if (isPhaseActive) {
+ const expediteProfile = vnavDriver.computeVerticalProfileForExpediteClimb();
+ [expeditePredToDistanceCell, expeditePredToTimeCell] = CDUPerformancePage.getTimeAndDistancePredictionsFromGeometryProfile(expediteProfile, altitudeToPredict, mcdu.flightPhaseManager.phase >= FmgcFlightPhases.TAKEOFF, true);
+ }
+ }
+
let managedSpeedCell = "";
if (isPhaseActive) {
if (mcdu.managedSpeedTarget === mcdu.managedSpeedClimb) {
@@ -438,7 +463,7 @@ class CDUPerformancePage {
managedSpeedCell = "{small}" + mcdu.managedSpeedTarget.toFixed(0) + "{end}";
}
} else {
- managedSpeedCell = (isSelected ? "*" : "") + mcdu.managedSpeedClimb > mcdu.managedSpeedLimit ? mcdu.managedSpeedLimit.toFixed(0) : mcdu.managedSpeedClimb.toFixed(0);
+ managedSpeedCell = (isSelected ? "*" : "") + mcdu.managedSpeedClimb > mcdu.climbSpeedLimit ? mcdu.climbSpeedLimit.toFixed(0) : mcdu.managedSpeedClimb.toFixed(0);
mcdu.onLeftInput[3] = (value, scratchpadCallback) => {
if (mcdu.trySetPreSelectedClimbSpeed(value)) {
@@ -491,16 +516,16 @@ class CDUPerformancePage {
};
mcdu.setTemplate([
["CLB[color]" + titleColor],
- ["ACT MODE", "EFOB", timeLabel],
- [actModeCell + "[color]green", "6.0[color]green", "----[color]green"],
+ ["ACT MODE"],
+ [actModeCell + "[color]green"],
["\xa0CI"],
- [costIndexCell + "[color]cyan"],
- ["\xa0MANAGED"],
- ["\xa0" + managedSpeedCell + "[color]green"],
+ [costIndexCell + "[color]cyan", predToCell, "\xa0\xa0\xa0{small}PRED TO{end}"],
+ ["\xa0MANAGED", "DIST", timeLabel],
+ ["\xa0" + managedSpeedCell + "[color]green", !isSelected ? predToDistanceCell : "", !isSelected ? predToTimeCell : ""],
["\xa0" + selectedSpeedTitle],
- ["\xa0" + selectedSpeedCell + "[color]cyan"],
- [""],
+ ["\xa0" + selectedSpeedCell, isSelected ? predToDistanceCell : "", isSelected ? predToTimeCell : ""],
[""],
+ isPhaseActive ? ["\xa0{small}EXPEDITE{end}[color]green", expeditePredToDistanceCell, expeditePredToTimeCell] : [""],
bottomRowLabels,
bottomRowCells
]);
@@ -591,7 +616,7 @@ class CDUPerformancePage {
["\xa0MANAGED"],
["\xa0" + managedSpeedCell + "[color]green"],
["\xa0" + selectedSpeedTitle],
- ["\xa0" + selectedSpeedCell + "[color]cyan"],
+ ["\xa0" + selectedSpeedCell],
["", "DES CABIN RATE>"],
["", "-350FT/MIN[color]green"],
bottomRowLabels,
@@ -684,7 +709,7 @@ class CDUPerformancePage {
["\xa0MANAGED"],
["\xa0" + managedSpeedCell + "[color]green"],
["\xa0" + selectedSpeedTitle],
- ["\xa0" + selectedSpeedCell + "[color]cyan"],
+ ["\xa0" + selectedSpeedCell],
[""],
[""],
bottomRowLabels,
@@ -1094,10 +1119,57 @@ class CDUPerformancePage {
static getSelectedTitleAndValue(_isPhaseActive, _isSelected, _preSel) {
if (_isPhaseActive) {
- return _isSelected ? ["SELECTED", "" + Math.round(Simplane.getAutoPilotMachModeActive() ? SimVar.GetGameVarValue('FROM MACH TO KIAS', 'number', Simplane.getAutoPilotMachHoldValue()) : Simplane.getAutoPilotAirspeedHoldValue())] : ["", ""];
+ return _isSelected
+ ? ["SELECTED", "" + Math.round(Simplane.getAutoPilotMachModeActive()
+ ? SimVar.GetGameVarValue('FROM MACH TO KIAS', 'number', Simplane.getAutoPilotMachHoldValue())
+ : Simplane.getAutoPilotAirspeedHoldValue()) + "[color]green"]
+ : ["", ""];
} else {
- return ["PRESEL", isFinite(_preSel) ? "" + _preSel : "*[ ]"];
+ return ["PRESEL", (isFinite(_preSel) ? "" + _preSel : "*[ ]") + "[color]cyan"];
+ }
+ }
+ static formatAltitudeOrLevel(altitudeToFormat, transitionAltitude) {
+ if (transitionAltitude >= 100 && altitudeToFormat > transitionAltitude) {
+ return `FL${(altitudeToFormat / 100).toFixed(0).toString().padStart(3,"0")}`;
}
+
+ return (10 * Math.round(altitudeToFormat / 10)).toFixed(0).toString().padStart(5,"\xa0");
+ }
+ static getTimeAndDistancePredictionsFromGeometryProfile(geometryProfile, altitudeToPredict, isFlying, printSmall = false) {
+ let predToDistanceCell = "---";
+ let predToTimeCell = "----";
+
+ if (!geometryProfile || !geometryProfile.isReadyToDisplay) {
+ return [predToTimeCell, predToDistanceCell];
+ }
+
+ const predictions = geometryProfile.computePredictionToFcuAltitude(altitudeToPredict);
+
+ if (predictions) {
+ if (isFinite(predictions.distanceFromStart)) {
+ if (printSmall) {
+ predToDistanceCell = "{small}" + predictions.distanceFromStart.toFixed(0) + "{end}[color]green";
+ } else {
+ predToDistanceCell = predictions.distanceFromStart.toFixed(0) + "[color]green";
+ }
+ }
+
+ if (isFinite(predictions.secondsFromPresent)) {
+ const utcTime = SimVar.GetGlobalVarValue("ZULU TIME", "seconds");
+
+ const predToTimeCellText = isFlying
+ ? FMCMainDisplay.secondsToUTC(utcTime + predictions.secondsFromPresent)
+ : FMCMainDisplay.minutesTohhmm(predictions.secondsFromPresent);
+
+ if (printSmall) {
+ predToTimeCell = "{small}" + predToTimeCellText + "{end}[color]green";
+ } else {
+ predToTimeCell = predToTimeCellText + "[color]green";
+ }
+ }
+ }
+
+ return [predToDistanceCell, predToTimeCell];
}
}
CDUPerformancePage._timer = 0;
diff --git a/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_ProgressPage.js b/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_ProgressPage.js
index 663cd5ab1937..d5a66119c9f6 100644
--- a/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_ProgressPage.js
+++ b/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_ProgressPage.js
@@ -12,6 +12,7 @@ class CDUProgressPage {
const adirsUsesGpsAsPrimary = SimVar.GetSimVarValue("L:A32NX_ADIRS_USES_GPS_AS_PRIMARY", "Bool");
const gpsPrimaryStatus = adirsUsesGpsAsPrimary ? "{green}GPS PRIMARY{end}" : "";
let flCrz = "-----";
+ let vDevCell = "";
switch (mcdu.flightPhaseManager.phase) {
case FmgcFlightPhases.PREFLIGHT:
case FmgcFlightPhases.TAKEOFF: {
@@ -34,6 +35,20 @@ class CDUProgressPage {
flCrz = "FL" + mcdu.cruiseFlightLevel.toFixed(0).padStart(3, "0") + "[color]cyan";
break;
}
+ case FmgcFlightPhases.DESCENT: {
+ const vDev = mcdu.guidanceController.vnavDriver.getLinearDeviation();
+ let vDevFormattedNumber = "{small}-----{end}";
+
+ if (vDev && isFinite(vDev)) {
+ const paddedVdev = (10 * Math.round(vDev / 10)).toFixed(0).padStart(4, "\xa0");
+ const vDevSign = vDev > 0 ? "+" : " ";
+ const extraSpace = paddedVdev.length > 4 ? "" : "\xa0";
+
+ vDevFormattedNumber = "{green}" + extraSpace + vDevSign + paddedVdev + "{end}";
+ }
+
+ vDevCell = "{small}VDEV={end}" + vDevFormattedNumber + "{small}FT{end}";
+ }
}
let flightPhase;
switch (mcdu.flightPhaseManager.phase) {
@@ -106,7 +121,7 @@ class CDUProgressPage {
["\xa0" + "CRZ\xa0", "OPT\xa0\xa0\xa0\xa0REC MAX"],
[flCrz, flOpt + "\xa0\xa0\xa0\xa0" + "{magenta}FL" + flMax.toString() + "\xa0{end}"],
[""],
- ["= 5 && flightPhase <= 7;
+ const transitionAltitude = mcdu.flightPlanManager.originTransitionAltitude;
+ const coordinator = mcdu.guidanceController.vnavDriver.stepCoordinator;
+ const predictions = mcdu.guidanceController.vnavDriver.currentNavGeometryProfile.waypointPredictions;
+
+ mcdu.setTemplate([
+ ["STEP ALTS {small}FROM{end} {green}FL" + mcdu._cruiseFlightLevel + "{end}"],
+ ["\xa0ALT\xa0/\xa0WPT", "DIST\xa0TIME"],
+ CDUStepAltsPage.formatStepClimbLine(mcdu, 0, predictions, isFlying, transitionAltitude),
+ [""],
+ CDUStepAltsPage.formatStepClimbLine(mcdu, 1, predictions, isFlying, transitionAltitude),
+ [""],
+ CDUStepAltsPage.formatStepClimbLine(mcdu, 2, predictions, isFlying, transitionAltitude),
+ [""],
+ CDUStepAltsPage.formatStepClimbLine(mcdu, 3, predictions, isFlying, transitionAltitude),
+ [""],
+ CDUStepAltsPage.formatOptStepLine(coordinator.steps),
+ [""],
+ [" CDUStepAltsPage.tryParseLeftInput(mcdu, coordinator, i, value);
+ }
+
+ mcdu.onLeftInput[4] = () => { };
+
+ mcdu.onLeftInput[5] = () => {
+ CDUStepAltsPage.Return();
+ };
+
+ mcdu.onRightInput[0] = () => { };
+ mcdu.onRightInput[1] = () => { };
+ mcdu.onRightInput[2] = () => { };
+ mcdu.onRightInput[3] = () => { };
+ mcdu.onRightInput[4] = () => { };
+ mcdu.onRightInput[5] = () => { };
+ }
+
+ static formatFl(altitude, transAlt) {
+ if (transAlt >= 100 && altitude > transAlt) {
+ return "FL" + Math.round(altitude / 100);
+ }
+ return altitude;
+ }
+
+ static formatOptStepLine(steps) {
+ if (steps.length > 0) {
+ return ["", ""];
+ }
+
+ return ["{small}OPT STEP:{end}", "{small}ENTER ALT ONLY{end}"];
+ }
+
+ static formatStepClimbLine(mcdu, index, predictions, isFlying, transitionAltitude) {
+ const emptyField = "[\xa0".padEnd(4, "\xa0") + "]";
+ const enteredStepAlts = mcdu.guidanceController.vnavDriver.stepCoordinator.steps;
+
+ if (index > enteredStepAlts.length) {
+ return [""];
+ } else if (index === enteredStepAlts.length) {
+ return ["{cyan}" + emptyField + "/" + emptyField + "{end}"];
+ } else {
+ const step = enteredStepAlts[index];
+
+ let distanceCell = "----";
+ let timeCell = "----";
+
+ const prediction = predictions.get(step.waypointIndex);
+ if (prediction) {
+ const { distanceFromStart, secondsFromPresent } = prediction;
+
+ if (isFinite(distanceFromStart)) {
+ distanceCell = "{green}" + Math.round(distanceFromStart).toFixed(0) + "{end}";
+ }
+
+ if (isFinite(secondsFromPresent)) {
+ const utcTime = SimVar.GetGlobalVarValue("ZULU TIME", "seconds");
+
+ timeCell = isFlying
+ ? `{green}${FMCMainDisplay.secondsToUTC(utcTime + secondsFromPresent)}[s-text]{end}`
+ : `{green}${FMCMainDisplay.secondsTohhmm(secondsFromPresent)}[s-text]{end}`;
+ }
+ }
+
+ const lastColumn = step.isIgnored ? "IGNORED" : distanceCell + "\xa0" + timeCell;
+
+ return ["{cyan}" + CDUStepAltsPage.formatFl(step.toAltitude, transitionAltitude) + "/" + step.ident + "{end}", lastColumn];
+ }
+ }
+
+ static tryParseLeftInput(mcdu, coordinator, index, input) {
+ if (index < coordinator.steps.length) {
+ return this.onClickExistingStepClimb(mcdu, coordinator, index, input);
+ }
+
+ // Create new step altitude
+ if (coordinator.steps.length >= 4) {
+ mcdu.addNewMessage(NXSystemMessages.notAllowed);
+ return false;
+ }
+
+ const splitInputs = input.split("/");
+ const altInput = splitInputs[0];
+ const waypointInput = splitInputs[1];
+
+ if (!waypointInput) {
+ mcdu.addNewMessage(NXSystemMessages.notAllowed);
+ return false;
+ // OPT STEP
+ }
+
+ const alt = this.tryParseAltitude(mcdu, altInput);
+ if (!alt) {
+ return false;
+ }
+
+ if (!coordinator.requestToAddGeographicStep(waypointInput, alt)) {
+ mcdu.addNewMessage(NXSystemMessages.formatError);
+ return false;
+ }
+
+ CDUStepAltsPage.ShowPage(mcdu);
+ return true;
+ }
+
+ static tryParseAltitude(mcdu, altitudeInput) {
+ let altValue = parseInt(altitudeInput);
+ if (altitudeInput.startsWith("FL")) {
+ altValue = parseInt(100 * altitudeInput.slice(2));
+ }
+
+ if (!isFinite(altValue) || !/^\d{4,5}$/.test(altitudeInput) && !/^FL\d{1,3}$/.test(altitudeInput)) {
+ mcdu.addNewMessage(NXSystemMessages.formatError);
+ return false;
+ }
+
+ altValue = Math.round(altValue / 10) * 10;
+ if (altValue < 1000 || altValue > 45000) {
+ mcdu.addNewMessage(NXSystemMessages.entryOutOfRange);
+ return false;
+ } else if (altValue > 39800) {
+ mcdu.addNewMessage(NXSystemMessages.stepAboveMaxFl);
+ return false;
+ }
+
+ return altValue;
+ }
+
+ static onClickExistingStepClimb(mcdu, coordinator, index, input) {
+ if (input === FMCMainDisplay.clrValue) {
+ coordinator.removeStep(index);
+ CDUStepAltsPage.ShowPage(mcdu);
+
+ return true;
+ }
+
+ // Edit step
+
+ return false;
+ }
+}
diff --git a/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_VerticalRevisionPage.js b/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_VerticalRevisionPage.js
index 88b264befb89..d93b0621c83b 100644
--- a/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_VerticalRevisionPage.js
+++ b/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_VerticalRevisionPage.js
@@ -5,7 +5,7 @@ const WaypointConstraintType = Object.freeze({
});
class CDUVerticalRevisionPage {
- static ShowPage(mcdu, waypoint, confirmSpeed = undefined, confirmAlt = undefined, confirmCode = undefined) {
+ static ShowPage(mcdu, waypoint, verticalWaypoint, isDescentWaypoint, confirmSpeed = undefined, confirmAlt = undefined, confirmCode = undefined) {
const waypointInfo = waypoint.infos;
if (waypointInfo instanceof WayPointInfo) {
mcdu.clearDisplay();
@@ -88,7 +88,9 @@ class CDUVerticalRevisionPage {
let l3Cell = "{cyan}*[\xa0\xa0\xa0]{end}";
let l4Title = "MACH/START WPT[color]inop";
let l4Cell = `\xa0{inop}[\xa0]/{small}${waypointIdent}{end}{end}`;
- let r5Cell = "STEP ALTS>[color]inop";
+ let r4Title = "";
+ let r4Cell = "";
+ let r5Cell = "";
if (isDestination) {
const hasGsIntercept = mcdu.flightPlanManager.getApproachType() === ApproachType.APPROACH_TYPE_ILS; // also GLS and MLS
@@ -132,6 +134,12 @@ class CDUVerticalRevisionPage {
if (speedConstraint) {
l3Cell = `{magenta}${speedConstraint}{end}`;
}
+
+ [r4Title, r4Cell] = this.formatAltErrorTitleAndValue(waypoint, verticalWaypoint);
+
+ if (mcdu._cruiseEntered && mcdu._cruiseFlightLevel) {
+ r5Cell = "STEP ALTS>";
+ };
}
mcdu.setTemplate([
@@ -142,8 +150,8 @@ class CDUVerticalRevisionPage {
[speedLimitCell, "RTA>[color]inop"],
[l3Title, r3Title],
[l3Cell, r3Cell],
- [l4Title, ""],
- [l4Cell, ""],
+ [l4Title, r4Title],
+ [l4Cell, r4Cell],
[""],
[" {}; // RTA
@@ -234,12 +242,12 @@ class CDUVerticalRevisionPage {
}
if (constraintType === WaypointConstraintType.Unknown) {
- CDUVerticalRevisionPage.ShowPage(mcdu, waypoint, speed);
+ CDUVerticalRevisionPage.ShowPage(mcdu, waypoint, verticalWaypoint, isDescentWaypoint, speed);
return;
}
mcdu.flightPlanManager.setWaypointSpeed(speed, wpIndex, () => {
- this.ShowPage(mcdu, waypoint);
+ this.ShowPage(mcdu, waypoint, verticalWaypoint, isDescentWaypoint);
}, constraintType === WaypointConstraintType.DES);
}; // SPD CSTR
mcdu.onRightInput[2] = (value, scratchpadCallback) => {
@@ -247,7 +255,7 @@ class CDUVerticalRevisionPage {
mcdu.flightPlanManager.setLegAltitudeDescription(waypoint, 0);
mcdu.flightPlanManager.setWaypointAltitude(0, wpIndex, () => {
mcdu.updateConstraints();
- this.ShowPage(mcdu, waypoint);
+ this.ShowPage(mcdu, waypoint, verticalWaypoint, isDescentWaypoint);
});
return;
}
@@ -269,7 +277,7 @@ class CDUVerticalRevisionPage {
}
if (constraintType === WaypointConstraintType.Unknown) {
- CDUVerticalRevisionPage.ShowPage(mcdu, waypoint, undefined, altitude, code);
+ CDUVerticalRevisionPage.ShowPage(mcdu, waypoint, verticalWaypoint, isDescentWaypoint, undefined, altitude, code);
return;
}
@@ -282,11 +290,19 @@ class CDUVerticalRevisionPage {
mcdu.onLeftInput[4] = () => {
//TODO: show appropriate wind page based on waypoint
CDUWindPage.Return = () => {
- CDUVerticalRevisionPage.ShowPage(mcdu, waypoint);
+ CDUVerticalRevisionPage.ShowPage(mcdu, waypoint, verticalWaypoint);
};
CDUWindPage.ShowPage(mcdu);
}; // WIND
- mcdu.onRightInput[4] = () => {}; // STEP ALTS
+ mcdu.onRightInput[4] = () => {
+ if (!mcdu._cruiseEntered || !mcdu._cruiseFlightLevel) {
+ return;
+ }
+ CDUStepAltsPage.Return = () => {
+ CDUVerticalRevisionPage.ShowPage(mcdu, waypoint, verticalWaypoint);
+ };
+ CDUStepAltsPage.ShowPage(mcdu);
+ }; // STEP ALTS
if (!confirmConstraint) {
mcdu.onLeftInput[5] = () => {
CDUFlightPlanPage.ShowPage(mcdu);
@@ -319,6 +335,7 @@ class CDUVerticalRevisionPage {
}
};
}
+
}
}
@@ -396,4 +413,29 @@ class CDUVerticalRevisionPage {
}, type === WaypointConstraintType.DES);
}
}
+
+ static formatAltErrorTitleAndValue(waypoint, verticalWaypoint) {
+ const empty = ["", ""];
+
+ if (!waypoint || !verticalWaypoint) {
+ return empty;
+ }
+
+ // No constraint
+ if (waypoint.legAltitudeDescription === 0 || verticalWaypoint.isAltitudeConstraintMet) {
+ return empty;
+ }
+
+ // Weird prediction error
+ if (!isFinite(verticalWaypoint.altError)) {
+ return empty;
+ }
+
+ let formattedAltError = (Math.round(verticalWaypoint.altError / 10) * 10).toFixed(0);
+ if (verticalWaypoint.altError > 0) {
+ formattedAltError = "+" + formattedAltError;
+ }
+
+ return ["ALT ERROR\xa0", "{green}{small}" + formattedAltError + "{end}{end}"];
+ }
}
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 d2f8374ad367..bf89ed689a71 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
@@ -1055,17 +1055,13 @@ class FMCMainDisplay extends BaseAirliners {
break;
}
case FmgcFlightPhases.DESCENT: {
- let speed = this.managedSpeedDescend;
+ // We fetch this data from VNAV
+ vPfd = SimVar.GetSimVarValue("L:A32NX_SPEEDS_MANAGED_PFD", "knots");
+ this.managedSpeedTarget = SimVar.GetSimVarValue("L:A32NX_SPEEDS_MANAGED_ATHR", "knots");
- if (this.descentSpeedLimit !== undefined && Math.round(SimVar.GetSimVarValue("INDICATED ALTITUDE", "feet") / 10) * 10 < 20 * (speed - this.descentSpeedLimit) + 300 + this.descentSpeedLimitAlt) {
- speed = Math.min(speed, this.descentSpeedLimit);
- }
-
- // TODO we really need VNAV to predict where along the leg we should slow to the constraint
- speed = Math.min(speed, this.getSpeedConstraint());
-
- [this.managedSpeedTarget, isMach] = this.getManagedTargets(speed, this.managedSpeedDescendMach);
- vPfd = this.managedSpeedTarget;
+ // Whether to use Mach or not should be based on the original managed speed, not whatever VNAV uses under the hood to vary it.
+ // Also, VNAV already does the conversion from Mach if necessary
+ isMach = this.getManagedTargets(this.managedSpeedDescend, this.managedSpeedDescendMach)[1];
break;
}
case FmgcFlightPhases.APPROACH: {
@@ -4835,6 +4831,111 @@ class FMCMainDisplay extends BaseAirliners {
representsDecimalNumber(str) {
return /^[+-]?\d*(?:\.\d+)?$/.test(str);
}
+
+ getZeroFuelWeight() {
+ return this.zeroFuelWeight * 2204.625;
+ }
+
+ getV2Speed() {
+ return SimVar.GetSimVarValue("L:AIRLINER_V2_SPEED", "knots");
+ }
+
+ getTropoPause() {
+ return this.tropo;
+ }
+
+ getManagedClimbSpeed() {
+ return this.managedSpeedClimb;
+ }
+
+ getManagedClimbSpeedMach() {
+ return this.managedSpeedClimbMach;
+ }
+
+ getManagedCruiseSpeed() {
+ return this.managedSpeedCruise;
+ }
+
+ getManagedCruiseSpeedMach() {
+ return this.managedSpeedCruiseMach;
+ }
+
+ getAccelerationAltitude() {
+ return this.accelerationAltitude;
+ }
+
+ getThrustReductionAltitude() {
+ return this.thrustReductionAltitude;
+ }
+
+ getCruiseAltitude() {
+ return this.cruiseFlightLevel * 100;
+ }
+
+ getFlightPhase() {
+ return this.flightPhaseManager.phase;
+ }
+ getClimbSpeedLimit() {
+ return {
+ speed: this.climbSpeedLimit,
+ underAltitude: this.climbSpeedLimitAlt,
+ };
+ }
+ getDescentSpeedLimit() {
+ return {
+ speed: this.descentSpeedLimit,
+ underAltitude: this.descentSpeedLimitAlt,
+ };
+ }
+ getPreSelectedClbSpeed() {
+ return this.preSelectedClbSpeed;
+ }
+ setClimbSpeedLimit(speedLimit, speedLimitAlt) {
+ this.climbSpeedLimit = speedLimit;
+ this.climbSpeedLimitAlt = speedLimitAlt;
+ }
+ setDescentSpeedLimit(speedLimit, speedLimitAlt) {
+ this.descentSpeedLimit = speedLimit;
+ this.descentSpeedLimitAlt = speedLimitAlt;
+ }
+ getTakeoffFlapsSetting() {
+ return this.flaps;
+ }
+ getManagedDescentSpeed() {
+ return this.managedSpeedDescend;
+ }
+ getManagedDescentSpeedMach() {
+ return this.managedSpeedDescendMach;
+ }
+ getApproachSpeed() {
+ return this.approachSpeeds && this.approachSpeeds.valid ? this.approachSpeeds.vapp : 0;
+ }
+ getFlapRetractionSpeed() {
+ return this.approachSpeeds && this.approachSpeeds.valid ? this.approachSpeeds.f : 0;
+ }
+ getSlatRetractionSpeed() {
+ return this.approachSpeeds && this.approachSpeeds.valid ? this.approachSpeeds.s : 0;
+ }
+ getCleanSpeed() {
+ return this.approachSpeeds && this.approachSpeeds.valid ? this.approachSpeeds.gd : 0;
+ }
+ getTripWind() {
+ return this.averageWind;
+ }
+ getWinds() {
+ return this.winds;
+ }
+ getApproachWind() {
+ const destination = this.flightPlanManager.getDestination();
+ if (!destination || !destination.infos && !destination.infos.coordinates || !isFinite(this.perfApprWindHeading)) {
+ return { direction: 0, speed: 0 };
+ }
+
+ const magVar = Facilities.getMagVar(destination.infos.coordinates.lat, destination.infos.coordinates.long);
+ const trueHeading = A32NX_Util.magneticToTrue(this.perfApprWindHeading, magVar);
+
+ return { direction: trueHeading, speed: this.perfApprWindSpeed };
+ }
}
FMCMainDisplay.clrValue = "\xa0\xa0\xa0\xa0\xa0CLR";
diff --git a/msfs-avionics-mirror/src/sdk/.npmrc b/msfs-avionics-mirror/src/sdk/.npmrc
new file mode 100644
index 000000000000..43c97e719a5a
--- /dev/null
+++ b/msfs-avionics-mirror/src/sdk/.npmrc
@@ -0,0 +1 @@
+package-lock=false
diff --git a/src/fmgc/src/components/fms-messages/FmsMessages.ts b/src/fmgc/src/components/fms-messages/FmsMessages.ts
index 01e95ab28d3c..a43e43ba533b 100644
--- a/src/fmgc/src/components/fms-messages/FmsMessages.ts
+++ b/src/fmgc/src/components/fms-messages/FmsMessages.ts
@@ -2,6 +2,7 @@
//
// SPDX-License-Identifier: GPL-3.0
+import { TdReached } from '@fmgc/components/fms-messages/TdReached';
import { FMMessage, FMMessageTriggers } from '@shared/FmMessages';
import { FmgcComponent } from '../FmgcComponent';
import { GpsPrimary } from './GpsPrimary';
@@ -33,6 +34,7 @@ export class FmsMessages implements FmgcComponent {
new GpsPrimaryLost(),
new MapPartlyDisplayedLeft(),
new MapPartlyDisplayedRight(),
+ new TdReached(),
];
init(): void {
diff --git a/src/fmgc/src/components/fms-messages/TdReached.ts b/src/fmgc/src/components/fms-messages/TdReached.ts
new file mode 100644
index 000000000000..261e44d0649a
--- /dev/null
+++ b/src/fmgc/src/components/fms-messages/TdReached.ts
@@ -0,0 +1,20 @@
+import { FMMessage, FMMessageTypes } from '@shared/FmMessages';
+import { FMMessageSelector, FMMessageUpdate } from './FmsMessages';
+
+export class TdReached implements FMMessageSelector {
+ message: FMMessage = FMMessageTypes.TdReached;
+
+ private lastState = false;
+
+ process(_: number): FMMessageUpdate {
+ const newState = SimVar.GetSimVarValue('L:A32NX_PFD_MSG_TD_REACHED', 'Bool') === 1;
+
+ if (newState !== this.lastState) {
+ this.lastState = newState;
+
+ return newState ? FMMessageUpdate.SEND : FMMessageUpdate.RECALL;
+ }
+
+ return FMMessageUpdate.NO_ACTION;
+ }
+}
diff --git a/src/fmgc/src/efis/EfisSymbols.ts b/src/fmgc/src/efis/EfisSymbols.ts
index 18240b9b5513..2d3f9f092122 100644
--- a/src/fmgc/src/efis/EfisSymbols.ts
+++ b/src/fmgc/src/efis/EfisSymbols.ts
@@ -12,6 +12,7 @@ import { GuidanceController } from '@fmgc/guidance/GuidanceController';
import { PathVector, PathVectorType } from '@fmgc/guidance/lnav/PathVector';
import { SegmentType } from '@fmgc/wtsdk';
import { distanceTo } from 'msfs-geo';
+import { VerticalWaypointPrediction } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
import { FlowEventSync } from '@shared/FlowEventSync';
import { LegType, RunwaySurface, TurnDirection, VorType } from '../types/fstypes/FSEnums';
import { NearbyFacilities } from './NearbyFacilities';
@@ -47,6 +48,8 @@ export class EfisSymbols {
private lastFpVersion;
+ private lastVnavDriverVersion: number = -1;
+
constructor(flightPlanManager: FlightPlanManager, guidanceController: GuidanceController) {
this.flightPlanManager = flightPlanManager;
this.guidanceController = guidanceController;
@@ -90,6 +93,8 @@ export class EfisSymbols {
const planCentre = this.flightPlanManager.getWaypoint(planCentreIndex)?.infos.coordinates;
const planCentreChanged = planCentre?.lat !== this.lastPlanCentre?.lat || planCentre?.long !== this.lastPlanCentre?.long;
this.lastPlanCentre = planCentre;
+ const vnavPredictionsChanged = this.lastVnavDriverVersion !== this.guidanceController.vnavDriver.version;
+ this.lastVnavDriverVersion = this.guidanceController.vnavDriver.version;
const activeFp = this.flightPlanManager.getCurrentFlightPlan();
// TODO temp f-pln
@@ -112,6 +117,12 @@ export class EfisSymbols {
return false;
};
+ let waypointPredictions = new Map();
+ // Check we are in an AP mode where constraints are not ignored
+ if (this.guidanceController.vnavDriver.shouldObeyAltitudeConstraints()) {
+ waypointPredictions = this.guidanceController.vnavDriver.currentNavGeometryProfile?.waypointPredictions;
+ }
+
for (const side of EfisSymbols.sides) {
const range = rangeSettings[SimVar.GetSimVarValue(`L:A32NX_EFIS_${side}_ND_RANGE`, 'number')];
const mode: Mode = SimVar.GetSimVarValue(`L:A32NX_EFIS_${side}_ND_MODE`, 'number');
@@ -125,7 +136,15 @@ export class EfisSymbols {
this.lastEfisOption[side] = efisOption;
const nearbyOverlayChanged = efisOption !== EfisOption.Constraints && efisOption !== EfisOption.None && nearbyFacilitiesChanged;
- if (!pposChanged && !trueHeadingChanged && !rangeChange && !modeChange && !efisOptionChange && !nearbyOverlayChanged && !fpChanged && !planCentreChanged) {
+ if (!pposChanged
+ && !trueHeadingChanged
+ && !rangeChange
+ && !modeChange
+ && !efisOptionChange
+ && !nearbyOverlayChanged
+ && !fpChanged
+ && !planCentreChanged
+ && !vnavPredictionsChanged) {
continue;
}
@@ -351,12 +370,26 @@ export class EfisSymbols {
direction = wp.additionalData.course;
}
- if (wp.legAltitudeDescription > 0 && wp.legAltitudeDescription < 6) {
- // TODO vnav to predict
- type |= NdSymbolTypeFlags.ConstraintUnknown;
+ if (i === activeFp.activeWaypointIndex) {
+ type |= NdSymbolTypeFlags.ActiveLegTermination;
+ }
+
+ const isBehindAircraft = i < activeFp.activeWaypointIndex;
+ const isInManagedNav = this.guidanceController.vnavDriver.isInManagedNav();
+
+ if (isInManagedNav && !isBehindAircraft && wp.legAltitudeDescription > 0 && wp.legAltitudeDescription < 6) {
+ const predictionAtWaypoint = waypointPredictions.get(i);
+
+ type |= NdSymbolTypeFlags.Constraint;
+
+ if (predictionAtWaypoint?.isAltitudeConstraintMet) {
+ type |= NdSymbolTypeFlags.MagentaColor;
+ } else if (predictionAtWaypoint) {
+ type |= NdSymbolTypeFlags.AmberColor;
+ }
}
- if (efisOption === EfisOption.Constraints) {
+ if (!isBehindAircraft && efisOption === EfisOption.Constraints) {
const descent = wp.constraintType === WaypointConstraintType.DES;
switch (wp.legAltitudeDescription) {
case 1:
@@ -423,12 +456,13 @@ export class EfisSymbols {
// Pseudo waypoints
- for (const pwp of this.guidanceController.currentPseudoWaypoints.filter((it) => it)) {
+ for (const pwp of this.guidanceController.currentPseudoWaypoints.filter((it) => it && it.displayedOnNd)) {
upsertSymbol({
databaseId: `W ${pwp.ident}`,
ident: pwp.ident,
- location: pwp.efisSymbolLla,
+ location: this.guidanceController.vnavDriver.isInManagedNav() ? pwp.efisSymbolLla : undefined,
type: pwp.efisSymbolFlag,
+ distanceFromAirplane: pwp.distanceFromStart,
});
}
diff --git a/src/fmgc/src/guidance/Geometry.ts b/src/fmgc/src/guidance/Geometry.ts
index 851dcde84572..72e024270544 100644
--- a/src/fmgc/src/guidance/Geometry.ts
+++ b/src/fmgc/src/guidance/Geometry.ts
@@ -497,14 +497,14 @@ export class Geometry {
let outboundLength = 0;
if (outbound) {
- if (outbound instanceof FixedRadiusTransition) {
+ if (outbound instanceof FixedRadiusTransition && !outbound.isReverted) {
// Type I transitions are split between the prev and next legs
outboundLength = outbound.distance / 2;
}
}
if (inbound) {
- if (inbound instanceof FixedRadiusTransition) {
+ if (inbound instanceof FixedRadiusTransition && !inbound.isReverted) {
// Type I transitions are split between the prev and next legs
inboundLength = inbound.distance / 2;
} else {
diff --git a/src/fmgc/src/guidance/GuidanceController.ts b/src/fmgc/src/guidance/GuidanceController.ts
index 84664d26e7f5..c6e44db680bf 100644
--- a/src/fmgc/src/guidance/GuidanceController.ts
+++ b/src/fmgc/src/guidance/GuidanceController.ts
@@ -4,7 +4,7 @@
// SPDX-License-Identifier: GPL-3.0
import { Geometry } from '@fmgc/guidance/Geometry';
-import { PseudoWaypoint } from '@fmgc/guidance/PsuedoWaypoint';
+import { PseudoWaypoint } from '@fmgc/guidance/PseudoWaypoint';
import { PseudoWaypoints } from '@fmgc/guidance/lnav/PseudoWaypoints';
import { EfisVectors } from '@fmgc/efis/EfisVectors';
import { Coordinates } from '@fmgc/flightplanning/data/geo';
@@ -15,6 +15,12 @@ import { HMLeg } from '@fmgc/guidance/lnav/legs/HX';
import { SimVarString } from '@shared/simvar';
import { getFlightPhaseManager } from '@fmgc/flightphase';
import { FmgcFlightPhase } from '@shared/flightphase';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { SpeedLimit } from '@fmgc/guidance/vnav/SpeedLimit';
+import { FlapConf } from '@fmgc/guidance/vnav/common';
+import { WindProfileFactory } from '@fmgc/guidance/vnav/wind/WindProfileFactory';
+import { FmcWinds, FmcWindVector } from '@fmgc/guidance/vnav/wind/types';
+import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions';
import { LnavDriver } from './lnav/LnavDriver';
import { FlightPlanManager, FlightPlans } from '../flightplanning/FlightPlanManager';
import { GuidanceManager } from './GuidanceManager';
@@ -23,6 +29,34 @@ import { VnavDriver } from './vnav/VnavDriver';
// How often the (milliseconds)
const GEOMETRY_RECOMPUTATION_TIMER = 5_000;
+export interface Fmgc {
+ getZeroFuelWeight(): number;
+ getFOB(): number;
+ getV2Speed(): Knots;
+ getTropoPause(): Feet;
+ getManagedClimbSpeed(): Knots;
+ getManagedClimbSpeedMach(): Mach;
+ getAccelerationAltitude(): Feet,
+ getThrustReductionAltitude(): Feet,
+ getCruiseAltitude(): Feet,
+ getFlightPhase(): FmgcFlightPhase,
+ getManagedCruiseSpeed(): Knots,
+ getManagedCruiseSpeedMach(): Mach,
+ getClimbSpeedLimit(): SpeedLimit,
+ getDescentSpeedLimit(): SpeedLimit,
+ getPreSelectedClbSpeed(): Knots,
+ getTakeoffFlapsSetting(): FlapConf | undefined
+ getManagedDescentSpeed(): Knots,
+ getManagedDescentSpeedMach(): Mach,
+ getApproachSpeed(): Knots,
+ getFlapRetractionSpeed(): Knots,
+ getSlatRetractionSpeed(): Knots,
+ getCleanSpeed(): Knots,
+ getTripWind(): number,
+ getWinds(): FmcWinds,
+ getApproachWind(): FmcWindVector,
+}
+
export class GuidanceController {
flightPlanManager: FlightPlanManager;
@@ -68,8 +102,14 @@ export class GuidanceController {
taskQueue = new TaskQueue();
+ verticalProfileComputationParametersObserver: VerticalProfileComputationParametersObserver;
+
private listener = RegisterViewListener('JS_LISTENER_SIMVARS', null, true);
+ private windProfileFactory: WindProfileFactory;
+
+ private atmosphericConditions: AtmosphericConditions;
+
get hasTemporaryFlightPlan() {
// eslint-disable-next-line no-underscore-dangle
return this.flightPlanManager._currentFlightPlanIndex === FlightPlans.Temporary;
@@ -161,13 +201,18 @@ export class GuidanceController {
}
}
- constructor(flightPlanManager: FlightPlanManager, guidanceManager: GuidanceManager) {
+ constructor(flightPlanManager: FlightPlanManager, guidanceManager: GuidanceManager, fmgc: Fmgc) {
this.flightPlanManager = flightPlanManager;
this.guidanceManager = guidanceManager;
+ this.verticalProfileComputationParametersObserver = new VerticalProfileComputationParametersObserver(fmgc);
+ this.windProfileFactory = new WindProfileFactory(this.verticalProfileComputationParametersObserver, fmgc, 1);
+
+ this.atmosphericConditions = new AtmosphericConditions(this.verticalProfileComputationParametersObserver);
+
this.lnavDriver = new LnavDriver(this);
- this.vnavDriver = new VnavDriver(this);
- this.pseudoWaypoints = new PseudoWaypoints(this);
+ this.vnavDriver = new VnavDriver(this, this.verticalProfileComputationParametersObserver, this.atmosphericConditions, this.windProfileFactory, flightPlanManager);
+ this.pseudoWaypoints = new PseudoWaypoints(this, this.atmosphericConditions);
this.efisVectors = new EfisVectors(this);
}
@@ -222,6 +267,14 @@ export class GuidanceController {
this.updateEfisState('L', this.leftEfisState);
this.updateEfisState('R', this.rightEfisState);
+ try {
+ this.verticalProfileComputationParametersObserver.update();
+ this.windProfileFactory.updateFmgcInputs();
+ } catch (e) {
+ console.error('[FMS] Error during update of VNAV input parameters. See exception below.');
+ console.error(e);
+ }
+
// Generate new geometry when flight plan changes
// TODO also need to do it when FMS perf params change, e.g. speed limit/alt, climb/crz/des speeds
const newFlightPlanVersion = this.flightPlanManager.currentFlightPlanVersion;
@@ -244,8 +297,13 @@ export class GuidanceController {
this.recomputeGeometries();
if (this.activeGeometry) {
- this.vnavDriver.acceptMultipleLegGeometry(this.activeGeometry);
- this.pseudoWaypoints.acceptMultipleLegGeometry(this.activeGeometry);
+ try {
+ this.vnavDriver.acceptMultipleLegGeometry(this.activeGeometry);
+ this.pseudoWaypoints.acceptMultipleLegGeometry(this.activeGeometry);
+ } catch (e) {
+ console.error('[FMS] Error during active geometry recomputation. See exception below.');
+ console.error(e);
+ }
}
} catch (e) {
console.error('[FMS] Error during geometry recomputation. See exception below.');
@@ -253,6 +311,8 @@ export class GuidanceController {
}
}
+ this.atmosphericConditions.update();
+
try {
this.updateMrpState();
} catch (e) {
@@ -413,4 +473,8 @@ export class GuidanceController {
holdLeg.setPredictedTas(tas);
}
}
+
+ getPresentPosition(): LatLongAlt {
+ return this.verticalProfileComputationParametersObserver.getPresentPosition();
+ }
}
diff --git a/src/fmgc/src/guidance/PsuedoWaypoint.ts b/src/fmgc/src/guidance/PseudoWaypoint.ts
similarity index 63%
rename from src/fmgc/src/guidance/PsuedoWaypoint.ts
rename to src/fmgc/src/guidance/PseudoWaypoint.ts
index 2501a8181708..7f9b6a9e3661 100644
--- a/src/fmgc/src/guidance/PsuedoWaypoint.ts
+++ b/src/fmgc/src/guidance/PseudoWaypoint.ts
@@ -2,7 +2,6 @@
// SPDX-License-Identifier: GPL-3.0
import { Coordinates } from '@fmgc/flightplanning/data/geo';
-import { WaypointStats } from '@fmgc/flightplanning/data/flightplan';
/**
* Types that tie pseudo waypoints to sequencing actions
@@ -19,6 +18,11 @@ export enum PseudoWaypointSequencingAction {
*/
APPROACH_PHASE_AUTO_ENGAGE,
+ /**
+ * Used to delete the step waypoint
+ */
+ STEP_REACHED,
+
}
export interface PseudoWaypoint {
@@ -54,14 +58,45 @@ export interface PseudoWaypoint {
*/
efisSymbolLla: Coordinates,
+ /**
+ * The distance from the start of the path
+ */
+ distanceFromStart: NauticalMiles,
+
/**
* Whether the pseudo waypoint is displayed on the MCDU
*/
displayedOnMcdu: boolean,
/**
- * Waypoint stats for the PWP
+ * THe MCDU F-PLN page ident, if different
+ */
+ mcduIdent?: string,
+
+ /**
+ * THe MCDU F-PLN page fix annotation, if applicable
+ */
+ mcduHeader?: string,
+
+ /**
+ * Additional information that is display if the waypoint is displayed on the MCDU (`displayedOnMcdu`)
+ */
+ flightPlanInfo?: PseudoWaypointFlightPlanInfo
+
+ /**
+ * Determines whether a PWP should show up as a symbol on the ND
*/
- stats: WaypointStats,
+ displayedOnNd: boolean,
+}
+
+export interface PseudoWaypointFlightPlanInfo {
+ distanceFromStart?: NauticalMiles,
+
+ altitude: Feet,
+
+ speed: Knots,
+
+ secondsFromPresent: Seconds,
+ distanceFromLastFix?: NauticalMiles,
}
diff --git a/src/fmgc/src/guidance/lnav/PseudoWaypoints.ts b/src/fmgc/src/guidance/lnav/PseudoWaypoints.ts
index b1f266a30a1d..1675d03e13b5 100644
--- a/src/fmgc/src/guidance/lnav/PseudoWaypoints.ts
+++ b/src/fmgc/src/guidance/lnav/PseudoWaypoints.ts
@@ -4,26 +4,64 @@
// SPDX-License-Identifier: GPL-3.0
import { GuidanceComponent } from '@fmgc/guidance/GuidanceComponent';
-import { PseudoWaypoint, PseudoWaypointSequencingAction } from '@fmgc/guidance/PsuedoWaypoint';
+import { PseudoWaypoint, PseudoWaypointFlightPlanInfo, PseudoWaypointSequencingAction } from '@fmgc/guidance/PseudoWaypoint';
import { VnavConfig, VnavDescentMode } from '@fmgc/guidance/vnav/VnavConfig';
import { NdSymbolTypeFlags } from '@shared/NavigationDisplay';
import { Geometry } from '@fmgc/guidance/Geometry';
import { Coordinates } from '@fmgc/flightplanning/data/geo';
-import { WaypointStats } from '@fmgc/flightplanning/data/flightplan';
import { GuidanceController } from '@fmgc/guidance/GuidanceController';
import { LateralMode } from '@shared/autopilot';
import { FixedRadiusTransition } from '@fmgc/guidance/lnav/transitions/FixedRadiusTransition';
import { Leg } from '@fmgc/guidance/lnav/legs/Leg';
-
+import { VerticalCheckpoint, VerticalCheckpointReason } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { TimeUtils } from '@fmgc/utils/TimeUtils';
+import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions';
+import { XFLeg } from '@fmgc/guidance/lnav/legs/XF';
+import { VMLeg } from '@fmgc/guidance/lnav/legs/VM';
+
+const PWP_IDENT_CLIMB_CONSTRAINT_LEVEL_OFF = 'Level off for climb constraint';
+const PWP_IDENT_CONTINUE_CLIMB = 'Continue climb';
+const PWP_SPEED_CHANGE = 'Speed change';
+const PWP_IDENT_TOC = '(T/C)';
+const PWP_IDENT_STEP_CLIMB = '(S/C)';
+const PWP_IDENT_STEP_DESCENT = '(S/D)';
+const PWP_IDENT_SPD_LIM = '(LIM)';
const PWP_IDENT_TOD = '(T/D)';
const PWP_IDENT_DECEL = '(DECEL)';
const PWP_IDENT_FLAP1 = '(FLAP1)';
const PWP_IDENT_FLAP2 = '(FLAP2)';
+const CHECKPOINT_REASONS_BEFORE_FCU_ALT_FOR_PWP: VerticalCheckpointReason[] = [
+ VerticalCheckpointReason.LevelOffForClimbConstraint,
+ VerticalCheckpointReason.ContinueClimb,
+ VerticalCheckpointReason.CrossingClimbSpeedLimit,
+ VerticalCheckpointReason.CrossingFcuAltitudeClimb,
+];
+
+const CHECKPOINT_REASONS_FOR_PWP: VerticalCheckpointReason[] = [
+ ...CHECKPOINT_REASONS_BEFORE_FCU_ALT_FOR_PWP,
+ VerticalCheckpointReason.CrossingFcuAltitudeClimb,
+ VerticalCheckpointReason.TopOfClimb,
+ VerticalCheckpointReason.StepClimb,
+ VerticalCheckpointReason.StepDescent,
+ VerticalCheckpointReason.TopOfDescent,
+ VerticalCheckpointReason.CrossingFcuAltitudeDescent,
+ VerticalCheckpointReason.CrossingDescentSpeedLimit,
+ VerticalCheckpointReason.LevelOffForDescentConstraint,
+ VerticalCheckpointReason.InterceptDescentProfileManaged,
+ VerticalCheckpointReason.InterceptDescentProfileSelected,
+ VerticalCheckpointReason.Decel,
+];
+
+const CDA_CHECKPOINT_FOR_PWP: Set = new Set([
+ VerticalCheckpointReason.Flaps1,
+ VerticalCheckpointReason.Flaps2,
+]);
+
export class PseudoWaypoints implements GuidanceComponent {
pseudoWaypoints: PseudoWaypoint[] = [];
- constructor(private guidanceController: GuidanceController) { }
+ constructor(private guidanceController: GuidanceController, private atmosphericConditions: AtmosphericConditions) { }
acceptVerticalProfile() {
if (DEBUG) {
@@ -49,98 +87,103 @@ export class PseudoWaypoints implements GuidanceComponent {
}
const newPseudoWaypoints: PseudoWaypoint[] = [];
+ const navGeometryProfile = this.guidanceController.vnavDriver.currentNavGeometryProfile;
+ const ndGeometryProfile = this.guidanceController.vnavDriver.currentNdGeometryProfile;
- if (VnavConfig.VNAV_EMIT_TOD) {
- const tod = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, this.guidanceController.vnavDriver.currentDescentProfile.tod, DEBUG && PWP_IDENT_TOD);
+ const totalDistance = navGeometryProfile.totalFlightPlanDistance;
- if (tod) {
- const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = tod;
+ const shouldEmitCdaPwp = VnavConfig.VNAV_DESCENT_MODE === VnavDescentMode.CDA && VnavConfig.VNAV_EMIT_CDA_FLAP_PWP;
- newPseudoWaypoints.push({
- ident: PWP_IDENT_TOD,
- sequencingType: PseudoWaypointSequencingAction.TOD_REACHED,
- alongLegIndex,
- distanceFromLegTermination,
- efisSymbolFlag: NdSymbolTypeFlags.PwpTopOfDescent,
- efisSymbolLla,
- displayedOnMcdu: true,
- stats: PseudoWaypoints.computePseudoWaypointStats(PWP_IDENT_TOD, geometry.legs.get(alongLegIndex), distanceFromLegTermination),
- });
+ // We do this so we only draw the first of each waypoint type
+ const waypointsLeftToDraw = new Set(CHECKPOINT_REASONS_FOR_PWP);
+
+ for (const checkpoint of [...navGeometryProfile.getCheckpointsInMcdu(), ...ndGeometryProfile.getCheckpointsToDrawOnNd()]) {
+ if (!waypointsLeftToDraw.has(checkpoint.reason) && (!CDA_CHECKPOINT_FOR_PWP.has(checkpoint.reason) || !shouldEmitCdaPwp)) {
+ continue;
}
- }
- if (VnavConfig.VNAV_EMIT_DECEL) {
- const decel = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, this.guidanceController.vnavDriver.currentApproachProfile.decel, DEBUG && PWP_IDENT_DECEL);
+ // Do not draw climb PWP past the FCU altitude
+ if (!waypointsLeftToDraw.has(VerticalCheckpointReason.CrossingFcuAltitudeClimb) && CHECKPOINT_REASONS_BEFORE_FCU_ALT_FOR_PWP.includes(checkpoint.reason)) {
+ continue;
+ }
- if (decel) {
- const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = decel;
+ waypointsLeftToDraw.delete(checkpoint.reason);
- newPseudoWaypoints.push({
- ident: PWP_IDENT_DECEL,
- sequencingType: PseudoWaypointSequencingAction.APPROACH_PHASE_AUTO_ENGAGE,
- alongLegIndex,
- distanceFromLegTermination,
- efisSymbolFlag: NdSymbolTypeFlags.PwpDecel,
- efisSymbolLla,
- displayedOnMcdu: true,
- stats: PseudoWaypoints.computePseudoWaypointStats(PWP_IDENT_DECEL, geometry.legs.get(alongLegIndex), distanceFromLegTermination),
- });
+ if (checkpoint.distanceFromStart < 0) {
+ continue;
}
- // for (let i = 0; i < 75; i++) {
- // const point = PseudoWaypoints.pointFromEndOfPath(geometry, this.guidanceController.vnavDriver.currentApproachProfile.decel + i / 2, `(BRUH${i}`);
- //
- // if (point) {
- // const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = point;
- //
- // newPseudoWaypoints.push({
- // ident: `(BRUH${i})`,
- // sequencingType: PseudoWaypointSequencingAction.TOD_REACHED,
- // alongLegIndex,
- // distanceFromLegTermination,
- // efisSymbolFlag: NdSymbolTypeFlags.PwpTopOfDescent,
- // efisSymbolLla,
- // displayedOnMcdu: true,
- // stats: PseudoWaypoints.computePseudoWaypointStats(`(BRUH${i})`, geometry.legs.get(alongLegIndex), distanceFromLegTermination),
- // });
- // }
- // }
+ const pwp = this.createPseudoWaypointFromVerticalCheckpoint(geometry, wptCount, totalDistance, checkpoint);
+ if (pwp) {
+ newPseudoWaypoints.push(pwp);
+ }
}
- if (VnavConfig.VNAV_DESCENT_MODE === VnavDescentMode.CDA && VnavConfig.VNAV_EMIT_CDA_FLAP_PWP) {
- const flap1 = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, this.guidanceController.vnavDriver.currentApproachProfile.flap1, DEBUG && PWP_IDENT_FLAP1);
+ // Speed Changes
+ const firstSpeedChange = ndGeometryProfile.findDistancesToSpeedChanges()[0];
- if (flap1) {
- const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = flap1;
+ if (firstSpeedChange) {
+ let [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = [undefined, undefined, undefined];
+ if (this.guidanceController.vnavDriver.isInManagedNav()) {
+ const pwp = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, totalDistance - firstSpeedChange);
- newPseudoWaypoints.push({
- ident: PWP_IDENT_FLAP1,
- alongLegIndex,
- distanceFromLegTermination,
- efisSymbolFlag: NdSymbolTypeFlags.PwpCdaFlap1,
- efisSymbolLla,
- displayedOnMcdu: true,
- stats: PseudoWaypoints.computePseudoWaypointStats(PWP_IDENT_FLAP1, geometry.legs.get(alongLegIndex), distanceFromLegTermination),
- });
+ if (pwp) {
+ [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = pwp;
+ }
+ }
+
+ newPseudoWaypoints.push({
+ ident: PWP_SPEED_CHANGE,
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpSpeedChange | NdSymbolTypeFlags.MagentaColor,
+ efisSymbolLla,
+ distanceFromStart: firstSpeedChange,
+ displayedOnMcdu: false,
+ displayedOnNd: true,
+ });
+ }
+
+ // Time Markers
+ for (const [time, prediction] of this.guidanceController.vnavDriver.timeMarkers.entries()) {
+ if (!this.guidanceController.vnavDriver.isInManagedNav() || !prediction) {
+ continue;
}
- const flap2 = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, this.guidanceController.vnavDriver.currentApproachProfile.flap2, DEBUG && PWP_IDENT_FLAP2);
+ const position = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, totalDistance - prediction.distanceFromStart, `TIME ${time}`);
+
+ if (position) {
+ const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = position;
- if (flap2) {
- const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = flap2;
+ const ident = TimeUtils.formatSeconds(time);
newPseudoWaypoints.push({
- ident: PWP_IDENT_FLAP2,
+ ident,
alongLegIndex,
distanceFromLegTermination,
- efisSymbolFlag: NdSymbolTypeFlags.PwpCdaFlap2,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpTimeMarker,
efisSymbolLla,
+ distanceFromStart: prediction.distanceFromStart,
displayedOnMcdu: true,
- stats: PseudoWaypoints.computePseudoWaypointStats(PWP_IDENT_FLAP2, geometry.legs.get(alongLegIndex), distanceFromLegTermination),
+ mcduIdent: `(${TimeUtils.formatSeconds(time, false)})`,
+ mcduHeader: '{white}{big}(UTC){end}{end}',
+ // TODO: Use `formatFlightPlanInfo` for this.
+ flightPlanInfo: {
+ ...prediction,
+ distanceFromLastFix: PseudoWaypoints.computePseudoWaypointDistanceFromFix(geometry.legs.get(alongLegIndex), distanceFromLegTermination),
+ },
+ displayedOnNd: true,
});
}
}
+ if (VnavConfig.DEBUG_PROFILE) {
+ const debugPoint = this.createDebugPwp(geometry, wptCount, totalDistance);
+ if (debugPoint) {
+ newPseudoWaypoints.push(debugPoint);
+ }
+ }
+
this.pseudoWaypoints = newPseudoWaypoints;
}
@@ -157,6 +200,7 @@ export class PseudoWaypoints implements GuidanceComponent {
const onPreviousLeg = pseudoWaypoint.alongLegIndex === this.guidanceController.activeLegIndex - 1;
const onActiveLeg = pseudoWaypoint.alongLegIndex === this.guidanceController.activeLegIndex;
const afterActiveLeg = pseudoWaypoint.alongLegIndex > this.guidanceController.activeLegIndex;
+ const inSelectedHdg = !this.guidanceController.vnavDriver.isInManagedNav();
// TODO we also consider the previous leg as active because we sequence Type I transitions at the same point
// for both guidance and legs list. IRL, the display sequences after the guidance, which means the pseudo-waypoints
@@ -165,7 +209,8 @@ export class PseudoWaypoints implements GuidanceComponent {
// We only want to add the pseudo waypoint if it's after the active leg or it isn't yet passed
if (
- afterActiveLeg
+ inSelectedHdg
+ || afterActiveLeg
|| (onPreviousLeg && this.guidanceController.displayActiveLegCompleteLegPathDtg > pseudoWaypoint.distanceFromLegTermination)
|| (onActiveLeg && this.guidanceController.activeLegCompleteLegPathDtg > pseudoWaypoint.distanceFromLegTermination)
) {
@@ -203,30 +248,24 @@ export class PseudoWaypoints implements GuidanceComponent {
]);
}
break;
+ case PseudoWaypointSequencingAction.STEP_REACHED:
+ // Since you cannot have steps behind you, the one getting sequenced should always be the first one
+ this.guidanceController.vnavDriver.stepCoordinator.removeStep(0);
+ break;
default:
}
}
/**
- * Computes a {@link WaypointStats} object for a pseudo waypoint
+ * Computes a the distance between the fix before the PWP and the PWP
*
- * @param ident the text identifier to give to this pseudo waypoint, for display on the MCDU
* @param leg the leg along which this pseudo waypoint is situated
* @param distanceAlongLeg the distance from the termination of the leg to this pseudo waypoint
*
* @private
*/
- private static computePseudoWaypointStats(ident: string, leg: Leg, distanceAlongLeg: number): WaypointStats {
- // TODO use predictions store to find out altitude, speed and time
- return {
- ident,
- bearingInFp: 0,
- distanceInFP: leg.distance - distanceAlongLeg,
- distanceFromPpos: 0,
- timeFromPpos: 0,
- etaFromPpos: 0,
- magneticVariation: 0,
- };
+ private static computePseudoWaypointDistanceFromFix(leg: Leg, distanceAlongLeg: number): NauticalMiles {
+ return leg.distance - distanceAlongLeg;
}
private static pointFromEndOfPath(
@@ -235,13 +274,17 @@ export class PseudoWaypoints implements GuidanceComponent {
distanceFromEnd: NauticalMiles,
debugString?: string,
): [lla: Coordinates, distanceFromLegTermination: number, legIndex: number] | undefined {
- if (distanceFromEnd < 0) {
- throw new Error('[FMS/PWP](pointFromEndOfPath) distanceFromEnd was negative');
+ if (!distanceFromEnd || distanceFromEnd < 0) {
+ if (VnavConfig.DEBUG_PROFILE) {
+ console.warn('[FMS/PWP](pointFromEndOfPath) distanceFromEnd was negative or undefined');
+ }
+
+ return undefined;
}
let accumulator = 0;
- if (false) {
+ if (DEBUG) {
console.log(`[FMS/PWP] Starting placement of PWP '${debugString}': dist: ${distanceFromEnd.toFixed(2)}nm`);
}
@@ -252,6 +295,18 @@ export class PseudoWaypoints implements GuidanceComponent {
continue;
}
+ let distanceInDiscontinuity = 0;
+ const nextLeg = path.legs.get(i + 1);
+ const previousLeg = path.legs.get(i - 1);
+
+ if (leg instanceof XFLeg && leg.fix.endsInDiscontinuity && nextLeg instanceof XFLeg) {
+ distanceInDiscontinuity = Avionics.Utils.computeGreatCircleDistance(leg.fix.infos.coordinates, nextLeg.fix.infos.coordinates);
+ } else if (leg instanceof VMLeg && previousLeg instanceof XFLeg && nextLeg instanceof XFLeg) {
+ distanceInDiscontinuity = Avionics.Utils.computeGreatCircleDistance(previousLeg.fix.infos.coordinates, nextLeg.fix.infos.coordinates);
+ }
+
+ accumulator += distanceInDiscontinuity;
+
const inboundTrans = path.transitions.get(i - 1);
const outboundTrans = path.transitions.get(i);
@@ -274,6 +329,12 @@ export class PseudoWaypoints implements GuidanceComponent {
}
if (accumulator > distanceFromEnd) {
+ if (accumulator - totalLegPathLength > distanceFromEnd) {
+ // Points lies on discontinuity (on the direct line between the two fixes)
+ // In this case, we don't want to place the
+ return undefined;
+ }
+
const distanceFromEndOfLeg = distanceFromEnd - (accumulator - totalLegPathLength);
let lla;
@@ -310,6 +371,7 @@ export class PseudoWaypoints implements GuidanceComponent {
return [lla, distanceFromEndOfLeg, i];
}
+ console.error(`[FMS/PseudoWaypoints] Tried to place PWP ${debugString} on ${leg.repr}, but failed`);
return undefined;
}
}
@@ -320,4 +382,233 @@ export class PseudoWaypoints implements GuidanceComponent {
return undefined;
}
+
+ private createPseudoWaypointFromVerticalCheckpoint(geometry: Geometry, wptCount: number, totalDistance: number, checkpoint: VerticalCheckpoint): PseudoWaypoint | undefined {
+ let [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = [undefined, undefined, undefined];
+ if (this.guidanceController.vnavDriver.isInManagedNav()) {
+ const pwp = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, totalDistance - checkpoint?.distanceFromStart);
+
+ if (pwp) {
+ [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = pwp;
+ } else {
+ console.warn('[FMS/VNAV] Could not place checkpoint:', checkpoint.reason);
+ }
+ }
+
+ switch (checkpoint.reason) {
+ case VerticalCheckpointReason.LevelOffForClimbConstraint:
+ return {
+ ident: PWP_IDENT_CLIMB_CONSTRAINT_LEVEL_OFF,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpClimbLevelOff | NdSymbolTypeFlags.MagentaColor,
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: false,
+ displayedOnNd: true,
+ };
+ case VerticalCheckpointReason.ContinueClimb:
+ return {
+ ident: PWP_IDENT_CONTINUE_CLIMB,
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpStartOfClimb | NdSymbolTypeFlags.CyanColor,
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: false,
+ displayedOnNd: true,
+ };
+ case VerticalCheckpointReason.CrossingClimbSpeedLimit:
+ return {
+ ident: PWP_IDENT_SPD_LIM,
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: 0, // Since this is not shown on the ND, it does not need a symbol
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: true,
+ mcduHeader: '(SPD)',
+ flightPlanInfo: this.formatFlightPlanInfo(checkpoint, geometry, alongLegIndex, distanceFromLegTermination),
+ displayedOnNd: false,
+ };
+ case VerticalCheckpointReason.CrossingDescentSpeedLimit:
+ return {
+ ident: PWP_IDENT_SPD_LIM,
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: 0, // Since this is not shown on the ND, it does not need a symbol
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: true,
+ mcduHeader: '(SPD)',
+ flightPlanInfo: this.formatFlightPlanInfo(checkpoint, geometry, alongLegIndex, distanceFromLegTermination),
+ displayedOnNd: false,
+ };
+ case VerticalCheckpointReason.CrossingFcuAltitudeClimb:
+ return {
+ ident: 'FCU alt',
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpClimbLevelOff | NdSymbolTypeFlags.CyanColor,
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: false,
+ displayedOnNd: true,
+ };
+ case VerticalCheckpointReason.TopOfClimb:
+ return {
+ ident: PWP_IDENT_TOC,
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: 0,
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: true,
+ flightPlanInfo: this.formatFlightPlanInfo(checkpoint, geometry, alongLegIndex, distanceFromLegTermination),
+ displayedOnNd: false,
+ };
+ case VerticalCheckpointReason.StepClimb:
+ return {
+ ident: PWP_IDENT_STEP_CLIMB,
+ sequencingType: PseudoWaypointSequencingAction.STEP_REACHED,
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpStartOfClimb,
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: true,
+ flightPlanInfo: this.formatFlightPlanInfo(checkpoint, geometry, alongLegIndex, distanceFromLegTermination),
+ displayedOnNd: true,
+ };
+ case VerticalCheckpointReason.StepDescent:
+ return {
+ ident: PWP_IDENT_STEP_DESCENT,
+ sequencingType: PseudoWaypointSequencingAction.STEP_REACHED,
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpTopOfDescent,
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: true,
+ flightPlanInfo: this.formatFlightPlanInfo(checkpoint, geometry, alongLegIndex, distanceFromLegTermination),
+ displayedOnNd: true,
+ };
+ case VerticalCheckpointReason.TopOfDescent:
+ return {
+ ident: PWP_IDENT_TOD,
+ sequencingType: PseudoWaypointSequencingAction.TOD_REACHED,
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpTopOfDescent,
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: true,
+ flightPlanInfo: this.formatFlightPlanInfo(checkpoint, geometry, alongLegIndex, distanceFromLegTermination),
+ displayedOnNd: true,
+ };
+ case VerticalCheckpointReason.CrossingFcuAltitudeDescent:
+ return {
+ ident: 'FCU alt',
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpDescentLevelOff | NdSymbolTypeFlags.CyanColor,
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: false,
+ displayedOnNd: true,
+ };
+ case VerticalCheckpointReason.InterceptDescentProfileSelected:
+ return {
+ ident: 'Intercept',
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpInterceptProfile,
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: false,
+ displayedOnNd: true,
+ };
+ case VerticalCheckpointReason.InterceptDescentProfileManaged:
+ return {
+ ident: 'Intercept',
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpInterceptProfile | NdSymbolTypeFlags.CyanColor,
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: false,
+ displayedOnNd: true,
+ };
+ case VerticalCheckpointReason.Decel:
+ return {
+ ident: PWP_IDENT_DECEL,
+ sequencingType: PseudoWaypointSequencingAction.APPROACH_PHASE_AUTO_ENGAGE,
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpDecel | NdSymbolTypeFlags.MagentaColor,
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: true,
+ flightPlanInfo: this.formatFlightPlanInfo(checkpoint, geometry, alongLegIndex, distanceFromLegTermination),
+ displayedOnNd: true,
+ };
+ case VerticalCheckpointReason.Flaps1:
+ return {
+ ident: PWP_IDENT_FLAP1,
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpCdaFlap1,
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: true,
+ flightPlanInfo: this.formatFlightPlanInfo(checkpoint, geometry, alongLegIndex, distanceFromLegTermination),
+ displayedOnNd: true,
+ };
+ case VerticalCheckpointReason.Flaps2:
+ return {
+ ident: PWP_IDENT_FLAP2,
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpCdaFlap2,
+ efisSymbolLla,
+ distanceFromStart: checkpoint.distanceFromStart,
+ displayedOnMcdu: true,
+ flightPlanInfo: this.formatFlightPlanInfo(checkpoint, geometry, alongLegIndex, distanceFromLegTermination),
+ displayedOnNd: true,
+ };
+ default:
+ return undefined;
+ }
+ }
+
+ private createDebugPwp(geometry: Geometry, wptCount: number, totalDistance: number): PseudoWaypoint | null {
+ const debugPoint = SimVar.GetSimVarValue('L:A32NX_FM_VNAV_DEBUG_POINT', 'number');
+ const position = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, totalDistance - debugPoint);
+ if (!position) {
+ return null;
+ }
+
+ const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = position;
+
+ return {
+ ident: 'DEBUG_POINT',
+ alongLegIndex,
+ distanceFromLegTermination,
+ efisSymbolFlag: NdSymbolTypeFlags.PwpSpeedChange | NdSymbolTypeFlags.CyanColor,
+ efisSymbolLla,
+ distanceFromStart: debugPoint,
+ displayedOnMcdu: false,
+ displayedOnNd: true,
+ };
+ }
+
+ private formatFlightPlanInfo(checkpoint: VerticalCheckpoint, geometry: Geometry, alongLegIndex: number, distanceFromLegTermination: number): PseudoWaypointFlightPlanInfo {
+ return {
+ ...checkpoint,
+ speed: this.atmosphericConditions.casOrMach(checkpoint.speed, checkpoint.mach, checkpoint.altitude),
+ distanceFromLastFix: Number.isFinite(alongLegIndex)
+ ? PseudoWaypoints.computePseudoWaypointDistanceFromFix(geometry.legs.get(alongLegIndex), distanceFromLegTermination)
+ : 0,
+ };
+ }
}
diff --git a/src/fmgc/src/guidance/lnav/legs/CI.ts b/src/fmgc/src/guidance/lnav/legs/CI.ts
index cc5ccc812137..51513d254128 100644
--- a/src/fmgc/src/guidance/lnav/legs/CI.ts
+++ b/src/fmgc/src/guidance/lnav/legs/CI.ts
@@ -151,10 +151,6 @@ export class CILeg extends Leg {
return 0;
}
- getPseudoWaypointLocation(_distanceBeforeTerminator: NauticalMiles): Coordinates | undefined {
- return undefined;
- }
-
isAbeam(ppos: Coordinates): boolean {
const dtg = courseToFixDistanceToGo(ppos, this.course, this.getPathEndPoint());
diff --git a/src/fmgc/src/guidance/lnav/legs/index.ts b/src/fmgc/src/guidance/lnav/legs/index.ts
index df133464be47..535ebbf2ff79 100644
--- a/src/fmgc/src/guidance/lnav/legs/index.ts
+++ b/src/fmgc/src/guidance/lnav/legs/index.ts
@@ -32,6 +32,8 @@ export interface SpeedConstraint {
speed: Knots,
}
+export type PathAngleConstraint = Degrees;
+
export abstract class FXLeg extends Leg {
from: WayPoint;
}
@@ -43,9 +45,11 @@ export function getAltitudeConstraintFromWaypoint(wp: WayPoint): AltitudeConstra
ac.altitude2 = undefined;
switch (wp.legAltitudeDescription) {
case 1:
+ case 6:
ac.type = AltitudeConstraintType.at;
break;
case 2:
+ case 7:
ac.type = AltitudeConstraintType.atOrAbove;
break;
case 3:
@@ -66,13 +70,18 @@ export function getAltitudeConstraintFromWaypoint(wp: WayPoint): AltitudeConstra
export function getSpeedConstraintFromWaypoint(wp: WayPoint): SpeedConstraint | undefined {
if (wp.speedConstraint) {
const sc: Partial = {};
- sc.type = SpeedConstraintType.at;
+ sc.type = SpeedConstraintType.atOrBelow;
sc.speed = wp.speedConstraint;
return sc as SpeedConstraint;
}
return undefined;
}
+export function getPathAngleConstraintFromWaypoint(wp: WayPoint): PathAngleConstraint | undefined {
+ // Check for null and undefined, we do this because 0 is falsy
+ return wp.verticalAngle;
+}
+
export function waypointToLocation(wp: WayPoint): LatLongData {
const loc: LatLongData = {
lat: wp.infos.coordinates.lat,
@@ -109,6 +118,11 @@ export interface LegMetadata {
*/
speedConstraint?: SpeedConstraint,
+ /**
+ * Path angle constraint applicable to this leg
+ */
+ pathAngleConstraint?: PathAngleConstraint,
+
/**
* UTC seconds required time of arrival applicable to the leg
*/
@@ -130,11 +144,13 @@ export interface LegMetadata {
export function legMetadataFromMsfsWaypoint(waypoint: WayPoint): LegMetadata {
const altitudeConstraint = getAltitudeConstraintFromWaypoint(waypoint);
const speedConstraint = getSpeedConstraintFromWaypoint(waypoint);
+ const pathAngleConstraint = getPathAngleConstraintFromWaypoint(waypoint);
return {
turnDirection: waypoint.turnDirection,
altitudeConstraint,
speedConstraint,
+ pathAngleConstraint,
isOverfly: waypoint.additionalData.overfly,
};
}
diff --git a/src/fmgc/src/guidance/lnav/transitions/DirectToFixTransition.ts b/src/fmgc/src/guidance/lnav/transitions/DirectToFixTransition.ts
index 297eefcccfaa..b05bc5d67e6a 100644
--- a/src/fmgc/src/guidance/lnav/transitions/DirectToFixTransition.ts
+++ b/src/fmgc/src/guidance/lnav/transitions/DirectToFixTransition.ts
@@ -289,9 +289,7 @@ export class DirectToFixTransition extends Transition {
const straightDistance = distanceTo(this.lineStartPoint, this.lineEndPoint);
if (this.hasArc) {
- const circumference = 2 * Math.PI * this.radius;
-
- return straightDistance + (circumference / 360 * this.arcSweepAngle);
+ return straightDistance + arcLength(this.radius, this.arcSweepAngle);
}
return straightDistance;
diff --git a/src/fmgc/src/guidance/vnav/AtmosphericConditions.ts b/src/fmgc/src/guidance/vnav/AtmosphericConditions.ts
index 3d4f21165e31..4e41007c0b88 100644
--- a/src/fmgc/src/guidance/vnav/AtmosphericConditions.ts
+++ b/src/fmgc/src/guidance/vnav/AtmosphericConditions.ts
@@ -1,6 +1,7 @@
// Copyright (c) 2022 FlyByWire Simulations
// SPDX-License-Identifier: GPL-3.0
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
import { Common } from './common';
export class AtmosphericConditions {
@@ -8,9 +9,6 @@ export class AtmosphericConditions {
private altitudeFromSim: Feet;
- // TODO use tropo from mcdu
- private tropo = 36090;
-
private casFromSim: Knots;
private tasFromSim: Knots;
@@ -21,7 +19,9 @@ export class AtmosphericConditions {
private computedIsaDeviation: Celcius;
- constructor() {
+ private pressureAltFromSim: Feet;
+
+ constructor(private observer: VerticalProfileComputationParametersObserver) {
this.update();
}
@@ -33,6 +33,7 @@ export class AtmosphericConditions {
// TODO filter?
this.windSpeedFromSim = SimVar.GetSimVarValue('AMBIENT WIND VELOCITY', 'Knots');
this.windDirectionFromSim = SimVar.GetSimVarValue('AMBIENT WIND DIRECTION', 'Degrees');
+ this.pressureAltFromSim = SimVar.GetSimVarValue('PRESSURE ALTITUDE', 'feet');
this.computedIsaDeviation = this.ambientTemperatureFromSim - Common.getIsaTemp(this.altitudeFromSim);
}
@@ -69,8 +70,12 @@ export class AtmosphericConditions {
return this.computedIsaDeviation;
}
+ private get tropoPause(): Feet {
+ return this.observer.get().tropoPause;
+ }
+
predictStaticAirTemperatureAtAltitude(altitude: Feet): number {
- return Common.getIsaTemp(altitude, altitude > this.tropo) + this.isaDeviation;
+ return Common.getIsaTemp(altitude, altitude > this.tropoPause) + this.isaDeviation;
}
totalAirTemperatureFromMach(altitude: Feet, mach: number) {
@@ -79,28 +84,87 @@ export class AtmosphericConditions {
}
computeMachFromCas(altitude: Feet, speed: Knots): number {
- const deltaSrs = Common.getDelta(altitude, altitude > this.tropo);
+ const deltaSrs = Common.getDelta(altitude, altitude > this.tropoPause);
return Common.CAStoMach(speed, deltaSrs);
}
computeCasFromMach(altitude: Feet, mach: Mach): number {
- const deltaSrs = Common.getDelta(altitude, altitude > this.tropo);
+ const deltaSrs = Common.getDelta(altitude, altitude > this.tropoPause);
return Common.machToCas(mach, deltaSrs);
}
computeCasFromTas(altitude: Feet, speed: Knots): Knots {
- const thetaSrs = Common.getTheta(altitude, this.isaDeviation, altitude > this.tropo);
- const deltaSrs = Common.getDelta(altitude, altitude > this.tropo);
+ const thetaSrs = Common.getTheta(altitude, this.isaDeviation, altitude > this.tropoPause);
+ const deltaSrs = Common.getDelta(altitude, altitude > this.tropoPause);
return Common.TAStoCAS(speed, thetaSrs, deltaSrs);
}
computeTasFromCas(altitude: Feet, speed: Knots): Knots {
- const thetaSrs = Common.getTheta(altitude, this.isaDeviation, altitude > this.tropo);
- const deltaSrs = Common.getDelta(altitude, altitude > this.tropo);
+ const thetaSrs = Common.getTheta(altitude, this.isaDeviation, altitude > this.tropoPause);
+ const deltaSrs = Common.getDelta(altitude, altitude > this.tropoPause);
return Common.CAStoTAS(speed, thetaSrs, deltaSrs);
}
+
+ /**
+ * Computes the ambient pressure measured at the static ports that was used to compute the indicated altitude.
+ * @param altitude An indicated altitude
+ * @param qnh The QNH setting at which the indicated altitude is measured
+ * @returns The estimated ambient pressure based on the indicated altitude for this QNH setting
+ */
+ private estimateAmbientPressure(altitude: Feet, qnh: Millibar): Millibar {
+ return qnh * (1 - altitude / 145442.15) ** (1 / 0.190263);
+ }
+
+ /**
+ * Computes the pressure altitude for a given ambient pressure. The pressure altitude is the altitude that would be indicated if the QNH was set to 1013.
+ * @param pressure The ambient pressure
+ * @returns
+ */
+ private computePressureAltitude(pressure: Millibar): Feet {
+ // Equation from Boeing Jet Transport Performance Methods document
+ return 145442.15 * (1 - ((pressure / 1013.25) ** 0.190263));
+ }
+
+ /**
+ * Estimates what the pressure altitude would be at a given altitude that was indicated for some QNH setting.
+ * If the QNH setting is 1013, the returned pressure altitude is the same as the indicated one
+ * @param altitude The indicated altitude to be converted to a pressure altitude
+ * @param qnh The QNH setting at which the indicated altitude is measured
+ * @returns
+ */
+ estimatePressureAltitude(altitude: Feet, qnh: Millibar) {
+ const ambientPressure = this.estimateAmbientPressure(altitude, qnh);
+ return this.computePressureAltitude(ambientPressure);
+ }
+
+ /**
+ * This is a hack because the QNH setting is a bit broken in MSFS as of 09/03/22.
+ * For now, we just linearly extrapolate the pressure altitude based on the linear deviation
+ * @param altitude The indicated altitude at which to estimate the pressure altitude
+ */
+ estimatePressureAltitudeInMsfs(altitude: Feet) {
+ // We add 2000 to avoid a division by zero
+ return this.pressureAltFromSim * (2000 + altitude) / (2000 + this.altitudeFromSim);
+ }
+
+ /**
+ * Returns a Mach number if the CAS is taken above crossover altitude.
+ * @param cas The corrected airspeed
+ * @param mach The Mach number which will be used if it is lower than the Mach number corresponding ot `cas`.
+ * @param altitude The altitude at which to perform the conversion
+ * @returns
+ */
+ casOrMach(cas: Knots, mach: Mach, altitude: Feet): Knots | Mach {
+ const machAsIas = this.computeCasFromMach(altitude, mach);
+
+ if (cas > machAsIas) {
+ return mach;
+ }
+
+ return cas;
+ }
}
diff --git a/src/fmgc/src/guidance/vnav/ConstraintReader.ts b/src/fmgc/src/guidance/vnav/ConstraintReader.ts
new file mode 100644
index 000000000000..33402959e6e8
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/ConstraintReader.ts
@@ -0,0 +1,233 @@
+import { FlightPlanManager } from '@fmgc/wtsdk';
+import { Leg } from '@fmgc/guidance/lnav/legs/Leg';
+import { ApproachPathAngleConstraint, DescentAltitudeConstraint, MaxAltitudeConstraint, MaxSpeedConstraint } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { Geometry } from '@fmgc/guidance/Geometry';
+import { AltitudeConstraintType, SpeedConstraintType } from '@fmgc/guidance/lnav/legs';
+import { FlightPlans, WaypointConstraintType } from '@fmgc/flightplanning/FlightPlanManager';
+import { AltitudeDescriptor } from '@fmgc/types/fstypes/FSEnums';
+import { VnavConfig } from '@fmgc/guidance/vnav/VnavConfig';
+import { VMLeg } from '@fmgc/guidance/lnav/legs/VM';
+
+export class ConstraintReader {
+ public climbAlitudeConstraints: MaxAltitudeConstraint[] = [];
+
+ public descentAltitudeConstraints: DescentAltitudeConstraint[] = [];
+
+ public climbSpeedConstraints: MaxSpeedConstraint[] = [];
+
+ public descentSpeedConstraints: MaxSpeedConstraint[] = [];
+
+ public flightPathAngleConstraints: ApproachPathAngleConstraint[] = []
+
+ public totalFlightPlanDistance = 0;
+
+ public distanceToPresentPosition = 0;
+
+ constructor(private flightPlanManager: FlightPlanManager) {
+ this.reset();
+ }
+
+ extract(geometry: Geometry, activeLegIndex: number, activeTransIndex: number, ppos: LatLongAlt) {
+ this.reset();
+
+ // We use these to keep track of constraints that have already been passed, yet should still be taken into account
+ let maxDescentSpeed = Infinity;
+ let maxDescentAltitude = Infinity;
+
+ for (let i = 0; i < this.flightPlanManager.getWaypointsCount(FlightPlans.Active); i++) {
+ const leg = geometry.legs.get(i);
+ this.updateDistanceFromStart(i, geometry, activeLegIndex, activeTransIndex, ppos);
+
+ const waypoint = this.flightPlanManager.getWaypoint(i, FlightPlans.Active);
+
+ // Here, we accumulate all the speed and altitude constraints behind the aircraft on waypoints that have already been sequenced.
+ if (i < activeLegIndex - 1) {
+ if (waypoint.additionalData.constraintType === WaypointConstraintType.DES /* DES */) {
+ if (waypoint.speedConstraint > 100) {
+ maxDescentSpeed = Math.min(maxDescentSpeed, waypoint.speedConstraint);
+ }
+
+ switch (waypoint.legAltitudeDescription) {
+ case AltitudeDescriptor.At:
+ case AltitudeDescriptor.AtOrBelow:
+ case AltitudeDescriptor.Between:
+ maxDescentAltitude = Math.min(maxDescentAltitude, Math.round(waypoint.legAltitude1));
+ break;
+ default:
+ // not constraining
+ }
+ }
+
+ continue;
+ // Once we reach the first waypoint that's still in the flight plan, we set the most constraining of the speed-/alt constraints on this waypoint.
+ } else if (i === activeLegIndex - 1) {
+ if (maxDescentSpeed < Infinity) {
+ this.descentSpeedConstraints.push({
+ distanceFromStart: 0,
+ maxSpeed: maxDescentSpeed,
+ });
+ }
+
+ if (maxDescentAltitude < Infinity) {
+ this.descentAltitudeConstraints.push({
+ distanceFromStart: 0,
+ constraint: {
+ type: AltitudeConstraintType.atOrBelow,
+ altitude1: maxDescentAltitude,
+ altitude2: undefined,
+ },
+ });
+ }
+ }
+
+ // I think this is only hit for manual discontinuities
+ if (!leg) {
+ continue;
+ }
+
+ if (waypoint.additionalData.constraintType === WaypointConstraintType.CLB) {
+ if (this.hasValidClimbAltitudeConstraint(leg)) {
+ this.climbAlitudeConstraints.push({
+ distanceFromStart: this.totalFlightPlanDistance,
+ maxAltitude: leg.metadata.altitudeConstraint.altitude1,
+ });
+ }
+
+ if (this.hasValidClimbSpeedConstraint(leg)) {
+ this.climbSpeedConstraints.push({
+ distanceFromStart: this.totalFlightPlanDistance,
+ maxSpeed: leg.metadata.speedConstraint.speed,
+ });
+ }
+ } else if (waypoint.additionalData.constraintType === WaypointConstraintType.DES) {
+ if (this.hasValidDescentAltitudeConstraint(leg)) {
+ this.descentAltitudeConstraints.push({
+ distanceFromStart: this.totalFlightPlanDistance,
+ constraint: leg.metadata.altitudeConstraint,
+ });
+ }
+
+ if (this.hasValidDescentSpeedConstraint(leg)) {
+ this.descentSpeedConstraints.push({
+ distanceFromStart: this.totalFlightPlanDistance,
+ maxSpeed: leg.metadata.speedConstraint.speed,
+ });
+ }
+
+ if (this.hasValidPathAngleConstraint(leg)) {
+ this.flightPathAngleConstraints.push({
+ distanceFromStart: this.totalFlightPlanDistance,
+ pathAngle: leg.metadata.pathAngleConstraint,
+ });
+ }
+ }
+ }
+
+ if (VnavConfig.DEBUG_PROFILE) {
+ console.log(`[FMS/VNAV] Total distance: ${this.totalFlightPlanDistance}`);
+ }
+ }
+
+ private hasValidSpeedConstraint(leg: Leg): boolean {
+ return leg.metadata.speedConstraint?.speed > 100 && leg.metadata.speedConstraint.type !== SpeedConstraintType.atOrAbove;
+ }
+
+ private hasValidClimbAltitudeConstraint(leg: Leg): boolean {
+ return leg.metadata.altitudeConstraint && leg.metadata.altitudeConstraint.type !== AltitudeConstraintType.atOrAbove
+ && (this.climbAlitudeConstraints.length < 1 || leg.metadata.altitudeConstraint.altitude1 >= this.climbAlitudeConstraints[this.climbAlitudeConstraints.length - 1].maxAltitude);
+ }
+
+ private hasValidClimbSpeedConstraint(leg: Leg): boolean {
+ return this.hasValidSpeedConstraint(leg)
+ && (this.climbSpeedConstraints.length < 1 || leg.metadata.speedConstraint.speed >= this.climbSpeedConstraints[this.climbSpeedConstraints.length - 1].maxSpeed);
+ }
+
+ private hasValidDescentAltitudeConstraint(leg: Leg): boolean {
+ return !!leg.metadata.altitudeConstraint;
+ }
+
+ private hasValidDescentSpeedConstraint(leg: Leg): boolean {
+ return this.hasValidSpeedConstraint(leg);
+ }
+
+ private hasValidPathAngleConstraint(leg: Leg) {
+ // We don't use strict equality because we want to check for null and undefined but not 0, which is falsy in JS
+ return leg.metadata.pathAngleConstraint != null;
+ }
+
+ resetAltitudeConstraints() {
+ this.climbAlitudeConstraints = [];
+ this.descentAltitudeConstraints = [];
+ }
+
+ resetSpeedConstraints() {
+ this.climbSpeedConstraints = [];
+ this.descentSpeedConstraints = [];
+ }
+
+ resetPathAngleConstraints() {
+ this.flightPathAngleConstraints = [];
+ }
+
+ reset() {
+ this.resetAltitudeConstraints();
+ this.resetSpeedConstraints();
+ this.resetPathAngleConstraints();
+
+ this.totalFlightPlanDistance = 0;
+ this.distanceToPresentPosition = 0;
+ }
+
+ private updateDistanceFromStart(index: number, geometry: Geometry, activeLegIndex: number, activeTransIndex: number, ppos: LatLongAlt) {
+ const { legs, transitions } = geometry;
+
+ const leg = legs.get(index);
+ const waypoint = this.flightPlanManager.getWaypoint(index, FlightPlans.Active);
+ const nextWaypoint = this.flightPlanManager.getWaypoint(index + 1, FlightPlans.Active);
+
+ if (!leg || leg.isNull) {
+ return;
+ }
+
+ const inboundTransition = transitions.get(index - 1);
+ const outboundTransition = transitions.get(index);
+
+ const [inboundLength, legDistance, outboundLength] = Geometry.completeLegPathLengths(
+ leg, (inboundTransition?.isNull || !inboundTransition?.isComputed) ? null : inboundTransition, outboundTransition,
+ );
+
+ const correctedInboundLength = Number.isNaN(inboundLength) ? 0 : inboundLength;
+ const totalLegLength = legDistance + correctedInboundLength + outboundLength;
+
+ this.totalFlightPlanDistance += totalLegLength;
+
+ if (waypoint.endsInDiscontinuity) {
+ const startingPointOfDisco = waypoint.discontinuityCanBeCleared
+ ? waypoint
+ : this.flightPlanManager.getWaypoint(index - 1, FlightPlans.Active); // MANUAL
+
+ this.totalFlightPlanDistance += Avionics.Utils.computeGreatCircleDistance(startingPointOfDisco.infos.coordinates, nextWaypoint.infos.coordinates);
+ }
+
+ if (index < activeLegIndex) {
+ this.distanceToPresentPosition += totalLegLength;
+ } else if (index === activeLegIndex) {
+ if (activeTransIndex < 0) {
+ const distanceToGo = leg instanceof VMLeg
+ ? Avionics.Utils.computeGreatCircleDistance(ppos, nextWaypoint.infos.coordinates)
+ : leg.getDistanceToGo(ppos);
+
+ // On a leg, not on any guided transition
+ this.distanceToPresentPosition += legDistance + correctedInboundLength - distanceToGo;
+ } else if (activeTransIndex === activeLegIndex) {
+ // On an outbound transition
+ this.distanceToPresentPosition += legDistance + correctedInboundLength - outboundTransition.getDistanceToGo(ppos) - outboundLength;
+ } else if (activeTransIndex === activeLegIndex - 1) {
+ // On an inbound transition
+ this.distanceToPresentPosition += correctedInboundLength - inboundTransition.getDistanceToGo(ppos);
+ } else {
+ console.error(`[FMS/VNAV] Unexpected transition index (legIndex: ${activeLegIndex}, transIndex: ${activeTransIndex})`);
+ }
+ }
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/CruiseToDescentCoordinator.ts b/src/fmgc/src/guidance/vnav/CruiseToDescentCoordinator.ts
new file mode 100644
index 000000000000..8524ecd289f3
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/CruiseToDescentCoordinator.ts
@@ -0,0 +1,128 @@
+import { CruisePathBuilder } from '@fmgc/guidance/vnav/cruise/CruisePathBuilder';
+import { DescentPathBuilder } from '@fmgc/guidance/vnav/descent/DescentPathBuilder';
+import { NavGeometryProfile, VerticalCheckpointReason } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { ClimbStrategy } from '@fmgc/guidance/vnav/climb/ClimbStrategy';
+import { DescentStrategy } from '@fmgc/guidance/vnav/descent/DescentStrategy';
+import { ApproachPathBuilder } from '@fmgc/guidance/vnav/descent/ApproachPathBuilder';
+import { SpeedProfile } from '@fmgc/guidance/vnav/climb/SpeedProfile';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { FmgcFlightPhase } from '@shared/flightphase';
+import { HeadwindProfile } from '@fmgc/guidance/vnav/wind/HeadwindProfile';
+import { TemporaryCheckpointSequence } from '@fmgc/guidance/vnav/profile/TemporaryCheckpointSequence';
+
+export class CruiseToDescentCoordinator {
+ private lastEstimatedFuelAtDestination: Pounds = 2300;
+
+ private lastEstimatedTimeAtDestination: Seconds = 0;
+
+ constructor(
+ private observer: VerticalProfileComputationParametersObserver,
+ private cruisePathBuilder: CruisePathBuilder,
+ private descentPathBuilder: DescentPathBuilder,
+ private approachPathBuilder: ApproachPathBuilder,
+ ) { }
+
+ resetEstimations() {
+ this.lastEstimatedFuelAtDestination = 2300;
+ this.lastEstimatedTimeAtDestination = 0;
+ }
+
+ buildCruiseAndDescentPath(
+ profile: NavGeometryProfile,
+ speedProfile: SpeedProfile,
+ cruiseWinds: HeadwindProfile,
+ descentWinds: HeadwindProfile,
+ stepClimbStrategy: ClimbStrategy,
+ stepDescentStrategy: DescentStrategy,
+ ) {
+ // - Start with initial guess for fuel on board at destination
+ // - Compute descent profile to get distance to T/D and burnt fuel during descent
+ // - Compute cruise profile to T/D -> guess new guess for fuel at start T/D, use fuel burn to get new estimate for fuel at destination
+ // - Repeat
+ const startingPointIndex = profile.findLastVerticalCheckpointIndex(
+ VerticalCheckpointReason.TopOfClimb,
+ VerticalCheckpointReason.PresentPosition,
+ );
+
+ if (startingPointIndex < 0) {
+ return;
+ }
+
+ const startingPoint = profile.checkpoints[startingPointIndex];
+
+ let iterationCount = 0;
+ let todFuelError = Infinity;
+ let todTimeError = Infinity;
+
+ if (Number.isNaN(this.lastEstimatedFuelAtDestination) || Number.isNaN(this.lastEstimatedTimeAtDestination)) {
+ this.resetEstimations();
+ }
+
+ let descentPath = new TemporaryCheckpointSequence();
+ let cruisePath = new TemporaryCheckpointSequence();
+
+ while (iterationCount++ < 4 && (Math.abs(todFuelError) > 100 || Math.abs(todTimeError) > 1)) {
+ descentPath = this.approachPathBuilder.computeApproachPath(profile, speedProfile, this.lastEstimatedFuelAtDestination, this.lastEstimatedTimeAtDestination);
+
+ if (descentPath.lastCheckpoint.reason !== VerticalCheckpointReason.Decel) {
+ console.error('[FMS/VNAV] Approach path did not end in DECEL. Discarding descent profile.');
+ return;
+ }
+
+ // Geometric and idle
+ this.descentPathBuilder.computeManagedDescentPath(descentPath, profile, speedProfile, descentWinds, this.cruisePathBuilder.getFinalCruiseAltitude());
+
+ if (descentPath.lastCheckpoint.reason !== VerticalCheckpointReason.TopOfDescent) {
+ console.error('[FMS/VNAV] Approach path did not end in T/D. Discarding descent profile.');
+ return;
+ }
+
+ // This means we are in the descent.
+ if (descentPath.lastCheckpoint.distanceFromStart < startingPoint.distanceFromStart) {
+ // At this point, there will still be a PresentPosition checkpoint in the profile, but we use it and remove it in DescentGuidance
+ profile.checkpoints.push(...descentPath.get(true).reverse());
+
+ return;
+ }
+
+ cruisePath = this.cruisePathBuilder.computeCruisePath(
+ profile, startingPoint, descentPath.lastCheckpoint.distanceFromStart, stepClimbStrategy, stepDescentStrategy, speedProfile, cruiseWinds,
+ );
+
+ if (!cruisePath) {
+ console.error('[FMS/VNAV] Could not coordinate cruise and descent path. Discarding descent profile');
+ return;
+ }
+
+ todFuelError = cruisePath.lastCheckpoint.remainingFuelOnBoard - descentPath.lastCheckpoint.remainingFuelOnBoard;
+ todTimeError = cruisePath.lastCheckpoint.secondsFromPresent - descentPath.lastCheckpoint.secondsFromPresent;
+
+ this.lastEstimatedFuelAtDestination += todFuelError;
+ this.lastEstimatedTimeAtDestination += todTimeError;
+ }
+
+ profile.checkpoints.push(...cruisePath.get());
+ profile.checkpoints.push(...descentPath.get(true).reverse());
+ }
+
+ addSpeedLimitAsCheckpoint(profile: NavGeometryProfile) {
+ const { flightPhase, descentSpeedLimit: { underAltitude }, presentPosition: { alt }, cruiseAltitude } = this.observer.get();
+
+ // Don't try to place speed limit if the cruise alt is higher
+ if (underAltitude > cruiseAltitude) {
+ return;
+ }
+
+ if ((underAltitude <= alt) && flightPhase >= FmgcFlightPhase.Descent) {
+ return;
+ }
+
+ const distance = profile.interpolateDistanceAtAltitudeBackwards(underAltitude);
+
+ profile.addInterpolatedCheckpoint(distance, { reason: VerticalCheckpointReason.CrossingDescentSpeedLimit });
+ }
+
+ canCompute(profile: NavGeometryProfile) {
+ return this.approachPathBuilder?.canCompute(profile);
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/EngineModel.ts b/src/fmgc/src/guidance/vnav/EngineModel.ts
index cb26c61f8a7f..6e180b018baa 100644
--- a/src/fmgc/src/guidance/vnav/EngineModel.ts
+++ b/src/fmgc/src/guidance/vnav/EngineModel.ts
@@ -4,6 +4,45 @@ export class EngineModel {
// In pounds of force. Used as a multiplier for results of table 1506
static maxThrust = 27120;
+ /**
+ * Maximum N1 in CLB thrust
+ * @param i row index (tat) in steps of 4°C
+ * @param j col index (pressure altitude)
+ * @returns Corrected N1 (CN1)
+ */
+ static maxClimbThrustTableLeap = [
+ [0, 2000, 5000, 8000, 12000, 15000, 17000, 20000, 24000, 27000, 31000, 35000, 39000, 41500],
+ [-54.0, 71.8, 73.6, 75.5, 76.8, 78.1, 78.9, 80.1, 81.5, 81.6, 83.0, 83.6, 83.7, 83.3],
+ [-50.0, 72.5, 74.3, 76.2, 77.5, 78.8, 79.6, 80.9, 82.2, 82.4, 83.8, 84.4, 84.5, 84.0],
+ [-46.0, 73.1, 75.0, 76.9, 78.2, 79.5, 80.3, 81.6, 83.0, 83.1, 84.5, 85.1, 85.3, 84.8],
+ [-42.0, 73.8, 75.6, 77.6, 78.9, 80.2, 81.0, 82.3, 83.7, 83.8, 85.3, 85.9, 86.0, 85.5],
+ [-38.0, 74.4, 76.3, 78.2, 79.6, 80.9, 81.7, 83.0, 84.4, 84.6, 86.0, 86.6, 86.7, 86.3],
+ [-34.0, 75.0, 76.9, 78.9, 80.3, 81.6, 82.4, 83.7, 85.1, 85.3, 86.7, 87.3, 87.5, 87.0],
+ [-30.0, 75.7, 77.6, 79.6, 80.9, 82.2, 83.1, 84.4, 85.8, 86.0, 87.5, 88.1, 88.2, 87.7],
+ [-26.0, 76.3, 78.2, 80.2, 81.6, 82.9, 83.8, 85.1, 86.5, 86.7, 88.2, 88.8, 88.9, 88.4],
+ [-22.0, 76.9, 78.8, 80.9, 82.2, 83.6, 84.4, 85.8, 87.2, 87.4, 88.9, 89.5, 89.6, 89.1],
+ [-18.0, 77.5, 79.5, 81.5, 82.9, 84.2, 85.1, 86.5, 87.9, 88.1, 89.6, 90.2, 90.0, 89.5],
+ [-14.0, 78.1, 80.1, 82.1, 83.5, 84.9, 85.8, 87.1, 88.6, 88.8, 90.3, 90.0, 89.2, 88.7],
+ [-10.0, 78.7, 80.7, 82.8, 84.2, 85.6, 86.4, 87.8, 89.3, 89.5, 91.0, 89.2, 88.4, 87.9],
+ [-6.0, 79.3, 81.3, 83.4, 84.8, 86.2, 87.1, 88.5, 90.0, 90.1, 91.1, 88.5, 87.7, 87.1],
+ [-2.0, 79.9, 81.9, 84.0, 85.5, 86.8, 87.7, 89.1, 90.6, 90.8, 90.2, 87.7, 86.9, 86.4],
+ [2.0, 80.5, 82.5, 84.6, 86.1, 87.5, 88.4, 89.8, 91.3, 90.3, 89.5, 87.0, 86.2, 85.6],
+ [6.0, 81.1, 83.1, 85.3, 86.7, 88.1, 89.0, 90.4, 90.5, 89.5, 88.8, 86.3, 85.5, 84.9],
+ [10.0, 81.6, 83.7, 85.9, 87.3, 88.7, 89.7, 90.0, 89.6, 88.7, 88.1, 85.6, 84.8, 84.2],
+ [14.0, 82.2, 84.3, 86.5, 87.9, 89.4, 89.3, 89.1, 88.7, 87.9, 87.5, 84.8, 83.9, 83.3],
+ [18.0, 82.8, 84.9, 87.1, 88.5, 88.6, 88.4, 88.3, 87.9, 87.2, 86.8, 86.8, 86.8, 86.8],
+ [22.0, 83.4, 85.5, 86.9, 88.0, 87.8, 87.7, 87.5, 87.2, 86.5, 86.1, 86.1, 86.1, 86.1],
+ [26.0, 83.9, 85.7, 86.2, 87.2, 87.1, 87.0, 86.8, 86.5, 85.8, 85.4, 85.4, 85.4, 85.4],
+ [30.0, 84.5, 84.9, 85.4, 86.5, 86.4, 86.3, 86.1, 85.8, 85.1, 85.1, 85.1, 85.1, 85.1],
+ [34.0, 83.8, 84.2, 84.7, 85.8, 85.7, 85.6, 85.5, 85.1, 85.1, 85.1, 85.1, 85.1, 85.1],
+ [38.0, 83.0, 83.4, 83.9, 85.1, 85.0, 84.9, 84.8, 84.8, 84.8, 84.8, 84.8, 84.8, 84.8],
+ [42.0, 82.2, 82.6, 83.1, 84.4, 84.4, 84.3, 84.3, 84.3, 84.3, 84.3, 84.3, 84.3, 84.3],
+ [46.0, 81.4, 81.8, 82.4, 83.7, 83.7, 83.7, 83.7, 83.7, 83.7, 83.7, 83.7, 83.7, 83.7],
+ [50.0, 80.6, 81.1, 81.6, 83.0, 83.0, 83.0, 83.0, 83.0, 83.0, 83.0, 83.0, 83.0, 83.0],
+ [54.0, 79.9, 80.4, 80.4, 80.4, 80.4, 80.4, 80.4, 80.4, 80.4, 80.4, 80.4, 80.4, 80.4],
+ [58.0, 79.2, 79.2, 79.2, 79.2, 79.2, 79.2, 79.2, 79.2, 79.2, 79.2, 79.2, 79.2, 79.2],
+ ];
+
/**
* Table 1502 - CN2 vs CN1 @ Mach 0, 0.2, 0.9
* n2_to_n1_table
@@ -131,6 +170,10 @@ export class EngineModel {
const interpolatedRowAtC1 = r1 === r2 ? table[r1][c1] : Common.interpolate(i, table[r1][0], table[r2][0], table[r1][c1], table[r2][c1]);
const interpolatedRowAtC2 = r1 === r2 ? table[r1][c2] : Common.interpolate(i, table[r1][0], table[r2][0], table[r1][c2], table[r2][c2]);
+ if (c1 === c2) {
+ return interpolatedRowAtC1;
+ }
+
return Common.interpolate(j, table[0][c1], table[0][c2], interpolatedRowAtC1, interpolatedRowAtC2);
}
@@ -223,4 +266,21 @@ export class EngineModel {
static getCorrectedThrust(uncorrectedThrust: number, delta2: number): number {
return uncorrectedThrust / delta2;
}
+
+ static getIdleN1(altitude: Feet, mach: Mach) {
+ const delta = Common.getDelta(altitude);
+ const iap = 1 / delta;
+
+ const theta = Common.getTheta(altitude, 0);
+ const theta2 = Common.getTheta2(theta, mach);
+
+ const lowMachCn2 = EngineModel.tableInterpolation(EngineModel.table1503, 0, iap);
+ const highMachCn2 = EngineModel.tableInterpolation(EngineModel.table1504, 0, iap);
+
+ const cn2 = Common.interpolate(mach, 0, 0.9, lowMachCn2, highMachCn2);
+ const cn1 = EngineModel.tableInterpolation(EngineModel.table1502, cn2, mach);
+
+ const n1 = cn1 * Math.sqrt(theta2);
+ return n1;
+ }
}
diff --git a/src/fmgc/src/guidance/vnav/FlightModel.ts b/src/fmgc/src/guidance/vnav/FlightModel.ts
index 3ba3f1b13d7a..411398404fa5 100644
--- a/src/fmgc/src/guidance/vnav/FlightModel.ts
+++ b/src/fmgc/src/guidance/vnav/FlightModel.ts
@@ -284,4 +284,11 @@ export class FlightModel {
const distanceToAccel = (initialSpeed * timeToAccel) + (0.5 * accel * (timeToAccel ** 2)); // TODO: Check units
return distanceToAccel;
}
+
+ static getGreenDotSpeedCas(
+ altitude: number,
+ weight: Kilograms,
+ ): Knots {
+ return weight / 500 + 85 + Math.max(0, (altitude - 20000) / 1000);
+ }
}
diff --git a/src/fmgc/src/guidance/vnav/Predictions.ts b/src/fmgc/src/guidance/vnav/Predictions.ts
index 42029c39b42e..74bb321f5c51 100644
--- a/src/fmgc/src/guidance/vnav/Predictions.ts
+++ b/src/fmgc/src/guidance/vnav/Predictions.ts
@@ -26,6 +26,7 @@ export interface StepResults {
initialAltitude?: number,
finalAltitude: number,
error?: VnavStepError,
+ speed?: Knots,
}
export class Predictions {
@@ -56,23 +57,22 @@ export class Predictions {
tropoAltitude: number,
speedbrakesExtended = false,
flapsConfig: FlapConf = FlapConf.CLEAN,
+ perfFactorPercent: number = 0,
): StepResults {
const midStepAltitude = initialAltitude + (stepSize / 2);
- const theta = Common.getTheta(midStepAltitude, isaDev);
- const delta = Common.getDelta(midStepAltitude);
+
+ const theta = Common.getTheta(midStepAltitude, isaDev, midStepAltitude > tropoAltitude);
+ const delta = Common.getDelta(midStepAltitude, midStepAltitude > tropoAltitude);
let mach = Common.CAStoMach(econCAS, delta);
- let eas;
- let tas;
- let usingMach = false;
+ let tas: Knots;
+ let accelFactorMode: AccelFactorMode = AccelFactorMode.CONSTANT_CAS;
// If above crossover altitude, use econMach
if (mach > econMach) {
mach = econMach;
- eas = Common.machToEAS(mach, delta);
tas = Common.machToTAS(mach, theta);
- usingMach = true;
+ accelFactorMode = AccelFactorMode.CONSTANT_MACH;
} else {
- eas = Common.CAStoEAS(econCAS, delta);
tas = Common.CAStoTAS(econCAS, theta, delta);
}
@@ -83,45 +83,35 @@ export class Predictions {
const correctedThrust = EngineModel.tableInterpolation(EngineModel.table1506, correctedN1, mach) * 2 * EngineModel.maxThrust;
const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(correctedN1, mach, midStepAltitude) * 2;
const thrust = EngineModel.getUncorrectedThrust(correctedThrust, delta2); // in lbf
- const fuelFlow = EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2); // in lbs/hour
+ const fuelFlow = Math.max(0, EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2) * (1 + perfFactorPercent / 100)); // in lbs/hour
const weightEstimate = zeroFuelWeight + initialFuelWeight;
- let pathAngle;
- let verticalSpeed;
- let stepTime;
- let distanceTraveled;
- let fuelBurned;
- let lift = weightEstimate;
+ let pathAngle: Radians;
+ let verticalSpeed: FeetPerMinute;
+ let stepTime: Seconds;
+ let distanceTraveled: NauticalMiles;
+ let fuelBurned: Pounds;
let midStepWeight = weightEstimate;
let previousMidStepWeight = midStepWeight;
let iterations = 0;
do {
- // Assume lift force is equal to weight as an initial approximation
- const liftCoefficient = FlightModel.getLiftCoefficientFromEAS(lift, eas);
- const dragCoefficient = FlightModel.getDragCoefficient(liftCoefficient, speedbrakesExtended, false, flapsConfig);
- const accelFactorMode = usingMach ? AccelFactorMode.CONSTANT_MACH : AccelFactorMode.CONSTANT_CAS;
- const accelFactor = Common.getAccelerationFactor(mach, midStepAltitude, isaDev, midStepAltitude > tropoAltitude, accelFactorMode);
- pathAngle = FlightModel.getConstantThrustPathAngleFromCoefficients(
- thrust,
- midStepWeight,
- liftCoefficient,
- dragCoefficient,
- accelFactor,
- );
+ const drag = FlightModel.getDrag(midStepWeight, mach, delta, speedbrakesExtended, false, flapsConfig);
+
+ const accelerationFactor = Common.getAccelerationFactor(mach, midStepAltitude, isaDev, midStepAltitude > tropoAltitude, accelFactorMode);
+ pathAngle = FlightModel.getConstantThrustPathAngle(thrust, midStepWeight, drag, accelerationFactor);
verticalSpeed = 101.268 * tas * Math.sin(pathAngle); // in feet per minute
- stepTime = stepSize / verticalSpeed; // in minutes
- distanceTraveled = (tas - headwindAtMidStepAlt) * (stepTime / 60); // in nautical miles
- fuelBurned = (fuelFlow / 60) * stepTime;
+ stepTime = verticalSpeed !== 0 ? 60 * stepSize / verticalSpeed : 0; // in seconds
+ distanceTraveled = (tas - headwindAtMidStepAlt) * (stepTime / 3600); // in nautical miles
+ fuelBurned = (fuelFlow / 3600) * stepTime;
// const endStepWeight = zeroFuelWeight + (initialFuelWeight - fuelBurned); <- not really needed
// Adjust variables for better accuracy next iteration
previousMidStepWeight = midStepWeight;
midStepWeight = zeroFuelWeight + (initialFuelWeight - (fuelBurned / 2));
- lift = midStepWeight * Math.cos(pathAngle);
iterations++;
- } while (iterations < 4 && Math.abs(previousMidStepWeight - midStepWeight) < 100);
+ } while (iterations < 4 && Math.abs(previousMidStepWeight - midStepWeight) > 10);
return {
pathAngle: pathAngle * MathUtils.RADIANS_TO_DEGREES,
@@ -129,7 +119,204 @@ export class Predictions {
timeElapsed: stepTime,
distanceTraveled,
fuelBurned,
+ initialAltitude,
finalAltitude: initialAltitude + stepSize,
+ speed: econCAS,
+ };
+ }
+
+ /**
+ * THIS IS DONE.
+ * @param initialAltitude altitude at beginning of step, in feet
+ * @param stepSize the size of the altitude step, in feet
+ * @param econCAS airspeed during climb (taking SPD LIM & restrictions into account)
+ * @param econMach mach during climb, after passing crossover altitude
+ * @param commandedN1 N1% at CLB (or idle) setting, depending on flight phase
+ * @param zeroFuelWeight zero fuel weight of the aircraft (from INIT B)
+ * @param initialFuelWeight weight of fuel at the end of last step
+ * @param headwindAtMidStepAlt headwind component (in knots) at initialAltitude + (stepSize / 2); tailwind is negative
+ * @param isaDev ISA deviation (in celsius)
+ * @param tropoAltitude tropopause altitude (feet)
+ * @param speedbrakesExtended whether or not speedbrakes are extended at half (for geometric segment path test only)
+ */
+ static distanceStep(
+ initialAltitude: number,
+ distance: number,
+ econCAS: number,
+ econMach: number,
+ commandedN1: number,
+ zeroFuelWeight: number,
+ initialFuelWeight: number,
+ headwindAtMidStepAlt: number,
+ isaDev: number,
+ tropoAltitude: number,
+ speedbrakesExtended = false,
+ flapsConfig: FlapConf = FlapConf.CLEAN,
+ perfFactorPercent: number = 0,
+ ): StepResults {
+ const weightEstimate = zeroFuelWeight + initialFuelWeight;
+
+ let finalAltitude = initialAltitude;
+ let previousFinalAltitude = finalAltitude;
+
+ let pathAngle: number;
+ let verticalSpeed: FeetPerMinute;
+ let stepTime: Seconds;
+ let stepSize: Feet;
+ let fuelBurned: Pounds;
+
+ let midStepWeight = weightEstimate;
+ let iterations = 0;
+ do {
+ const midStepAltitude = (initialAltitude + finalAltitude) / 2;
+
+ const theta = Common.getTheta(midStepAltitude, isaDev, midStepAltitude > tropoAltitude);
+ const delta = Common.getDelta(midStepAltitude, midStepAltitude > tropoAltitude);
+ let mach = Common.CAStoMach(econCAS, delta);
+
+ let tas: Knots;
+ let accelFactorMode: AccelFactorMode = AccelFactorMode.CONSTANT_CAS;
+ // If above crossover altitude, use econMach
+ if (mach > econMach) {
+ mach = econMach;
+ tas = Common.machToTAS(mach, theta);
+ accelFactorMode = AccelFactorMode.CONSTANT_MACH;
+ } else {
+ tas = Common.CAStoTAS(econCAS, theta, delta);
+ }
+
+ // Engine model calculations
+ const theta2 = Common.getTheta2(theta, mach);
+ const delta2 = Common.getDelta2(delta, mach);
+ const correctedN1 = EngineModel.getCorrectedN1(commandedN1, theta2);
+ const correctedThrust = EngineModel.tableInterpolation(EngineModel.table1506, correctedN1, mach) * 2 * EngineModel.maxThrust;
+ const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(correctedN1, mach, initialAltitude) * 2;
+ const thrust = EngineModel.getUncorrectedThrust(correctedThrust, delta2); // in lbf
+ const fuelFlow = Math.max(0, EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2) * (1 + perfFactorPercent / 100)); // in lbs/hour
+
+ const drag = FlightModel.getDrag(midStepWeight, mach, delta, speedbrakesExtended, false, flapsConfig);
+
+ const accelerationFactor = Common.getAccelerationFactor(mach, midStepAltitude, isaDev, midStepAltitude > tropoAltitude, accelFactorMode);
+ pathAngle = FlightModel.getConstantThrustPathAngle(thrust, midStepWeight, drag, accelerationFactor);
+
+ verticalSpeed = 101.268 * tas * Math.sin(pathAngle); // in feet per minute
+ stepTime = (tas - headwindAtMidStepAlt) !== 0 ? 3600 * distance / (tas - headwindAtMidStepAlt) : 0; // in seconds
+ stepSize = stepTime / 60 * verticalSpeed;
+ fuelBurned = (fuelFlow / 3600) * stepTime;
+ // const endStepWeight = zeroFuelWeight + (initialFuelWeight - fuelBurned); <- not really needed
+
+ // Adjust variables for better accuracy next iteration
+ previousFinalAltitude = finalAltitude;
+ midStepWeight = zeroFuelWeight + (initialFuelWeight - (fuelBurned / 2));
+ finalAltitude = initialAltitude + stepSize;
+ iterations++;
+ } while (iterations < 4 && Math.abs(finalAltitude - previousFinalAltitude) > 10);
+
+ return {
+ pathAngle: pathAngle * MathUtils.RADIANS_TO_DEGREES,
+ verticalSpeed,
+ timeElapsed: stepTime,
+ distanceTraveled: distance,
+ fuelBurned,
+ initialAltitude,
+ finalAltitude,
+ speed: econCAS,
+ };
+ }
+
+ /**
+ * THIS IS DONE.
+ * @param initialAltitude altitude at beginning of step, in feet
+ * @param stepSize the size of the altitude step, in feet
+ * @param econCAS airspeed during climb (taking SPD LIM & restrictions into account)
+ * @param econMach mach during climb, after passing crossover altitude
+ * @param commandedN1 N1% at CLB (or idle) setting, depending on flight phase
+ * @param zeroFuelWeight zero fuel weight of the aircraft (from INIT B)
+ * @param initialFuelWeight weight of fuel at the end of last step
+ * @param headwindAtMidStepAlt headwind component (in knots) at initialAltitude + (stepSize / 2); tailwind is negative
+ * @param isaDev ISA deviation (in celsius)
+ * @param tropoAltitude tropopause altitude (feet)
+ * @param speedbrakesExtended whether or not speedbrakes are extended at half (for geometric segment path test only)
+ */
+ static reverseDistanceStep(
+ finalAltitude: number,
+ distance: number,
+ econCAS: number,
+ econMach: number,
+ commandedN1: number,
+ zeroFuelWeight: number,
+ initialFuelWeight: number,
+ headwindAtMidStepAlt: number,
+ isaDev: number,
+ tropoAltitude: number,
+ speedbrakesExtended = false,
+ flapsConfig: FlapConf = FlapConf.CLEAN,
+ perfFactorPercent: number = 0,
+ ): StepResults {
+ const weightEstimate = zeroFuelWeight + initialFuelWeight;
+
+ let initialAltitude = finalAltitude;
+ let pathAngle: number;
+ let verticalSpeed: FeetPerMinute;
+ let stepTime: Seconds;
+ let stepSize: Feet;
+ let fuelBurned: Pounds;
+
+ let midStepWeight = weightEstimate;
+ let previousMidStepWeight = midStepWeight;
+ let iterations = 0;
+ do {
+ const theta = Common.getTheta(initialAltitude, isaDev, initialAltitude > tropoAltitude);
+ const delta = Common.getDelta(initialAltitude, initialAltitude > tropoAltitude);
+ let mach = Common.CAStoMach(econCAS, delta);
+
+ let tas: Knots;
+ let accelFactorMode: AccelFactorMode = AccelFactorMode.CONSTANT_CAS;
+ // If above crossover altitude, use econMach
+ if (mach > econMach) {
+ mach = econMach;
+ tas = Common.machToTAS(mach, theta);
+ accelFactorMode = AccelFactorMode.CONSTANT_MACH;
+ } else {
+ tas = Common.CAStoTAS(econCAS, theta, delta);
+ }
+
+ // Engine model calculations
+ const theta2 = Common.getTheta2(theta, mach);
+ const delta2 = Common.getDelta2(delta, mach);
+ const correctedN1 = EngineModel.getCorrectedN1(commandedN1, theta2);
+ const correctedThrust = EngineModel.tableInterpolation(EngineModel.table1506, correctedN1, mach) * 2 * EngineModel.maxThrust;
+ const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(correctedN1, mach, initialAltitude) * 2;
+ const thrust = EngineModel.getUncorrectedThrust(correctedThrust, delta2); // in lbf
+ const fuelFlow = Math.max(0, EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2) * (1 + perfFactorPercent / 100)); // in lbs/hour
+
+ const drag = FlightModel.getDrag(midStepWeight, mach, delta, speedbrakesExtended, false, flapsConfig);
+
+ const accelerationFactor = Common.getAccelerationFactor(mach, initialAltitude, isaDev, initialAltitude > tropoAltitude, accelFactorMode);
+ pathAngle = FlightModel.getConstantThrustPathAngle(thrust, midStepWeight, drag, accelerationFactor);
+
+ verticalSpeed = 101.268 * tas * Math.sin(pathAngle); // in feet per minute
+ stepTime = (tas - headwindAtMidStepAlt) !== 0 ? 3600 * distance / (tas - headwindAtMidStepAlt) : 0; // in seconds
+ stepSize = stepTime / 60 * verticalSpeed;
+ fuelBurned = (fuelFlow / 3600) * stepTime;
+ // const endStepWeight = zeroFuelWeight + (initialFuelWeight - fuelBurned); <- not really needed
+
+ // Adjust variables for better accuracy next iteration
+ previousMidStepWeight = midStepWeight;
+ midStepWeight = zeroFuelWeight + (initialFuelWeight - (fuelBurned / 2));
+ initialAltitude = finalAltitude - stepSize;
+ iterations++;
+ } while (iterations < 4 && Math.abs(previousMidStepWeight - midStepWeight) > 100);
+
+ return {
+ pathAngle: pathAngle * MathUtils.RADIANS_TO_DEGREES,
+ verticalSpeed,
+ timeElapsed: stepTime,
+ distanceTraveled: distance,
+ fuelBurned,
+ initialAltitude,
+ finalAltitude,
+ speed: econCAS,
};
}
@@ -158,7 +345,7 @@ export class Predictions {
const delta = Common.getDelta(altitude);
let mach = Common.CAStoMach(econCAS, delta);
- let tas;
+ let tas: Knots;
// If above crossover altitude, use econMach
if (mach > econMach) {
mach = econMach;
@@ -180,21 +367,24 @@ export class Predictions {
const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(correctedN1, mach, altitude) * 2;
const fuelFlow = EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2); // in lbs/hour
- const stepTime = ((tas - headwind) / stepSize) / 60; // in minutes
- const fuelBurned = (fuelFlow / 60) * stepTime;
-
- let result: StepResults;
- result.pathAngle = 0;
- result.verticalSpeed = 0;
- result.timeElapsed = stepTime;
- result.distanceTraveled = stepSize;
- result.fuelBurned = fuelBurned;
- result.finalAltitude = altitude;
- return result;
+ const stepTime = (stepSize / (tas - headwind)) * 3600; // in seconds
+ const fuelBurned = (fuelFlow / 3600) * stepTime;
+
+ return {
+ pathAngle: 0,
+ verticalSpeed: 0,
+ timeElapsed: stepTime,
+ distanceTraveled: stepSize,
+ fuelBurned,
+ finalAltitude: altitude,
+ initialAltitude: altitude,
+ speed: econCAS,
+ };
}
/**
* THIS IS DONE.
+ * @param flightPathAngle flight path angle (in degrees) to fly the speed change step at
* @param initialAltitude altitude at beginning of step, in feet
* @param initialCAS airspeed at beginning of step
* @param finalCAS airspeed at end of step
@@ -211,7 +401,7 @@ export class Predictions {
* @param minimumAbsoluteAcceleration the minimum absolute acceleration before emitting TOO_LOW_DECELERATION (kts/s)
*/
static speedChangeStep(
- flightPahAngle: number,
+ flightPathAngle: number,
initialAltitude: number,
initialCAS: number,
finalCAS: number,
@@ -227,39 +417,27 @@ export class Predictions {
flapConfig = FlapConf.CLEAN,
minimumAbsoluteAcceleration?: number,
): StepResults {
- const theta = Common.getTheta(initialAltitude, isaDev);
- const delta = Common.getDelta(initialAltitude);
+ const theta = Common.getTheta(initialAltitude, isaDev, initialAltitude > tropoAltitude);
+ const delta = Common.getDelta(initialAltitude, initialAltitude > tropoAltitude);
let actualInitialMach = Common.CAStoMach(initialCAS, delta);
let actualFinalMach = Common.CAStoMach(finalCAS, delta);
- let initialTas;
- let finalTas;
- // let initialEas;
- // let finalEas;
+ let initialTas: Knots;
+ let finalTas: Knots;
- // let usingMachAtStart;
// If above crossover altitude, use mach
if (actualInitialMach > initialMach) {
actualInitialMach = initialMach;
initialTas = Common.machToTAS(actualInitialMach, theta);
- // initialEas = Common.machToEAS(actualInitialMach, delta);
- // usingMachAtStart = true;
} else {
initialTas = Common.CAStoTAS(initialCAS, theta, delta);
- // initialEas = Common.CAStoEAS(initialCAS, delta);
- // usingMachAtStart = false;
}
- // let usingMachAtEnd;
if (actualFinalMach > finalMach) {
actualFinalMach = finalMach;
finalTas = Common.machToTAS(actualFinalMach, theta);
- // finalEas = Common.machToEAS(actualFinalMach, delta);
- // usingMachAtEnd = true;
} else {
finalTas = Common.CAStoTAS(finalCAS, theta, delta);
- // finalEas = Common.CAStoEAS(finalCAS, delta);
- // usingMachAtEnd = false;
}
const averageMach = (actualInitialMach + actualFinalMach) / 2;
@@ -276,14 +454,14 @@ export class Predictions {
const weightEstimate = zeroFuelWeight + initialFuelWeight;
- const pathAngleRadians = flightPahAngle * MathUtils.DEGREES_TO_RADIANS;
+ const pathAngleRadians = flightPathAngle * MathUtils.DEGREES_TO_RADIANS;
- let error;
- let verticalSpeed;
- let stepTime;
- let distanceTraveled;
- let fuelBurned;
- let finalAltitude;
+ let error: VnavStepError | null;
+ let verticalSpeed: FeetPerMinute;
+ let stepTime: Seconds;
+ let distanceTraveled: NauticalMiles;
+ let fuelBurned: Pounds;
+ let finalAltitude: Feet;
let lift = weightEstimate;
let midStepWeight = weightEstimate;
let previousMidStepWeight = midStepWeight;
@@ -300,26 +478,13 @@ export class Predictions {
error = VnavStepError.AVAILABLE_GRADIENT_INSUFFICIENT;
}
+ // TODO: Why are we using 10 here?
const acceleration = FlightModel.accelerationForGradient(
availableGradient,
pathAngleRadians,
- 9.81,
+ 10,
);
- // TODO what do we do with this
- // const accelFactorMode = usingMachAtStart ? AccelFactorMode.CONSTANT_MACH : AccelFactorMode.CONSTANT_CAS;
- // const accelFactor = Common.getAccelerationFactor(averageMach,
- // initialAltitude,
- // isaDev,
- // initialAltitude > tropoAltitude,
- // accelFactorMode);
-
- // pathAngle = FlightModel.fpaForGradient(
- // availableGradient,
- // FlightModel.requiredAccelRateMS2,
- // accelFactor,
- // );
-
const accelerationKNS = (FlightModel.requiredAccelRateKNS * acceleration) / FlightModel.requiredAccelRateMS2;
if (Math.abs(accelerationKNS) < minimumAbsoluteAcceleration) {
@@ -329,17 +494,17 @@ export class Predictions {
error = VnavStepError.TOO_LOW_DECELERATION;
}
- stepTime = Math.abs(finalTas - initialTas) / Math.abs(accelerationKNS);
+ stepTime = Math.abs(finalTas - initialTas) / Math.abs(accelerationKNS); // in seconds
distanceTraveled = (stepTime / 3600) * averageTas;
- verticalSpeed = 101.268 * averageTas * Math.sin(pathAngleRadians); // in feet per minute
+ verticalSpeed = 101.268 * (averageTas - headwindAtInitialAltitude) * Math.sin(pathAngleRadians); // in feet per minute
// // TODO: double-check if accel rate operates on TAS or CAS
// stepTime = Math.abs(finalTas - initialTas) / accelerationKNS; // in seconds
finalAltitude = initialAltitude + (verticalSpeed * (stepTime / 60)); // in feet
// TODO: now that we have final altitude, we could get accurate mid-step headwind instead of using initial headwind...
// distanceTraveled = (averageTas - headwindAtInitialAltitude) * (stepTime / 3_600); // in NM
- fuelBurned = (fuelFlow / 60) * stepTime;
+ fuelBurned = (fuelFlow / 3600) * stepTime;
// const endStepWeight = zeroFuelWeight + (initialFuelWeight - fuelBurned); <- not really needed
// Adjust variables for better accuracy next iteration
@@ -347,7 +512,7 @@ export class Predictions {
midStepWeight = zeroFuelWeight + (initialFuelWeight - (fuelBurned / 2));
lift = midStepWeight * Math.cos(pathAngleRadians);
iterations++;
- } while (iterations < 4 && Math.abs(previousMidStepWeight - midStepWeight) < 100);
+ } while (iterations < 4 && Math.abs(previousMidStepWeight - midStepWeight) > 100);
return {
pathAngle: pathAngleRadians * MathUtils.RADIANS_TO_DEGREES,
@@ -355,8 +520,10 @@ export class Predictions {
timeElapsed: stepTime,
distanceTraveled,
fuelBurned,
+ initialAltitude,
finalAltitude,
error,
+ speed: finalCAS,
};
}
@@ -418,6 +585,8 @@ export class Predictions {
* @param initialFuelWeight weight of fuel at the end of last step
* @param isaDev ISA deviation (in celsius)
* @param tropoAltitude tropopause altitude (feet)
+ * @param gearExtended whether or not the landing gear is extended
+ * @param flapConfig the current flap configuration
*/
static geometricStep(
initialAltitude: number,
@@ -428,7 +597,10 @@ export class Predictions {
zeroFuelWeight: number,
initialFuelWeight: number,
isaDev: number,
+ headwind: number,
tropoAltitude: number,
+ gearExtended: boolean,
+ flapConfig: FlapConf,
): StepResults {
const distanceInFeet = distance * 6076.12;
const fpaRadians = Math.atan((finalAltitude - initialAltitude) / distanceInFeet);
@@ -441,13 +613,13 @@ export class Predictions {
let eas;
let tas;
- let usingMach = false;
+ let accelFactorMode: AccelFactorMode = AccelFactorMode.CONSTANT_CAS;
// If above crossover altitude, use econMach
if (mach > econMach) {
mach = econMach;
eas = Common.machToEAS(mach, delta);
tas = Common.machToTAS(mach, theta);
- usingMach = true;
+ accelFactorMode = AccelFactorMode.CONSTANT_MACH;
} else {
eas = Common.CAStoEAS(econCAS, delta);
tas = Common.CAStoTAS(econCAS, theta, delta);
@@ -457,18 +629,17 @@ export class Predictions {
const theta2 = Common.getTheta2(theta, mach);
const delta2 = Common.getDelta2(delta, mach);
- let thrust;
- let verticalSpeed;
- let stepTime;
- let fuelBurned;
+ let thrust: number; // In lbs force
+ let verticalSpeed: FeetPerMinute;
+ let stepTime: Seconds;
+ let fuelBurned: Pounds;
let lift = weightEstimate * Math.cos(fpaRadians);
let midStepWeight = weightEstimate;
let previousMidStepWeight = midStepWeight;
let iterations = 0;
do {
const liftCoefficient = FlightModel.getLiftCoefficientFromEAS(lift, eas);
- const dragCoefficient = FlightModel.getDragCoefficient(liftCoefficient);
- const accelFactorMode = usingMach ? AccelFactorMode.CONSTANT_MACH : AccelFactorMode.CONSTANT_CAS;
+ const dragCoefficient = FlightModel.getDragCoefficient(liftCoefficient, false, gearExtended, flapConfig);
const accelFactor = Common.getAccelerationFactor(mach, midStepAltitude, isaDev, midStepAltitude > tropoAltitude, accelFactorMode);
thrust = FlightModel.getThrustFromConstantPathAngleCoefficients(
@@ -479,8 +650,8 @@ export class Predictions {
accelFactor,
);
- verticalSpeed = 101.268 * tas * Math.sin(fpaRadians); // in feet per minute
- stepTime = (finalAltitude - initialAltitude) / verticalSpeed; // in minutes
+ verticalSpeed = 101.268 * (tas - headwind) * Math.sin(fpaRadians); // in feet per minute
+ stepTime = verticalSpeed !== 0 ? 60 * (finalAltitude - initialAltitude) / verticalSpeed : 0; // in seconds
// Divide by 2 to get thrust per engine
const correctedThrust = (thrust / delta2) / 2;
@@ -489,14 +660,14 @@ export class Predictions {
const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(correctedN1, mach, midStepAltitude) * 2;
const fuelFlow = EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2); // in lbs/hour
- fuelBurned = (fuelFlow / 60) * stepTime;
+ fuelBurned = (fuelFlow / 3600) * stepTime;
// Adjust variables for better accuracy next iteration
previousMidStepWeight = midStepWeight;
midStepWeight = zeroFuelWeight + (initialFuelWeight - (fuelBurned / 2));
lift = midStepWeight * Math.cos(fpaRadians);
iterations++;
- } while (iterations < 4 && Math.abs(previousMidStepWeight - midStepWeight) < 100);
+ } while (iterations < 4 && Math.abs(previousMidStepWeight - midStepWeight) > 100);
return {
pathAngle: fpaDegrees,
@@ -505,76 +676,457 @@ export class Predictions {
distanceTraveled: distance,
fuelBurned,
finalAltitude,
+ initialAltitude,
+ speed: econCAS,
};
}
- // static constantSlopeSegment(
- //
- // ): StepResults {
- // // e = ((T - D / W)
- // // a = g * (sin(available climb angle) - sin (desired fpa))
- // // d = ((final velocity squared) - (initial velocity squared)) / (2 * a)
- // }
-
- /**
- * THIS IS DONE.
- * @param initialAltitude altitude at beginning of step, in feet
- * @param finalAltitude altitude at end of step, in feet
- * @param distance distance of step, in NM
- * @param econCAS airspeed during step
- * @param econMach mach during step
- * @param idleN1 N1% at idle setting
- * @param zeroFuelWeight zero fuel weight of the aircraft (from INIT B)
- * @param initialFuelWeight weight of fuel at the end of last step
- * @param isaDev ISA deviation (in celsius)
- */
- static decelerationFromGeometricStep(
+ static verticalSpeedStep(
initialAltitude: number,
finalAltitude: number,
+ verticalSpeed: number,
econCAS: number,
econMach: number,
- idleN1: number,
zeroFuelWeight: number,
initialFuelWeight: number,
isaDev: number,
- ): number {
- const distanceInFeet = distance * 6076.12;
- const fpaRadians = Math.atan((finalAltitude - initialAltitude) / distanceInFeet);
- const fpaDegrees = fpaRadians * MathUtils.RADIANS_TO_DEGREES;
+ headwind: number,
+ perfFactorPercent: number,
+ ): StepResults & { predictedN1: number } {
const midStepAltitude = (initialAltitude + finalAltitude) / 2;
const theta = Common.getTheta(midStepAltitude, isaDev);
const delta = Common.getDelta(midStepAltitude);
+
let mach = Common.CAStoMach(econCAS, delta);
+ const delta2 = Common.getDelta2(delta, mach);
+ const theta2 = Common.getTheta2(theta, mach);
- let eas;
+ let tas: Knots;
+ let accelFactorMode: AccelFactorMode = AccelFactorMode.CONSTANT_CAS;
// If above crossover altitude, use econMach
if (mach > econMach) {
mach = econMach;
- eas = Common.machToEAS(mach, delta);
+ tas = Common.machToTAS(mach, theta);
+ accelFactorMode = AccelFactorMode.CONSTANT_MACH;
} else {
- eas = Common.CAStoEAS(econCAS, delta);
+ tas = Common.CAStoTAS(econCAS, theta, delta);
}
- const theta2 = Common.getTheta2(theta, mach);
- const delta2 = Common.getDelta2(delta, mach);
- const correctedN1 = EngineModel.getCorrectedN1(idleN1, theta2);
- const correctedThrust = EngineModel.tableInterpolation(EngineModel.table1506, correctedN1, mach) * 2 * EngineModel.maxThrust;
- const thrust = EngineModel.getUncorrectedThrust(correctedThrust, delta2); // in lbf
+ const pathAngle: Radians = Math.atan2(verticalSpeed, tas * 101.269); // radians
+ const stepTime: Seconds = 60 * (finalAltitude - initialAltitude) / verticalSpeed; // seconds
+ const distanceTraveled: NauticalMiles = (tas - headwind) * Math.cos(pathAngle) * stepTime / 3600;
+
+ let fuelBurned = 0;
+ let iterations = 0;
+ let midstepWeight = zeroFuelWeight + initialFuelWeight;
+ let previousMidstepWeight = midstepWeight;
+ let predictedN1 = 0;
+ do {
+ const drag = FlightModel.getDrag(midstepWeight, mach, delta, false, false, FlapConf.CLEAN);
+ const thrust = FlightModel.getThrustFromConstantPathAngle(pathAngle * MathUtils.RADIANS_TO_DEGREES, midstepWeight, drag, accelFactorMode);
+
+ const correctedThrust = (thrust / delta2) / 2;
+ // Since table 1506 describes corrected thrust as a fraction of max thrust, divide it
+ predictedN1 = EngineModel.reverseTableInterpolation(EngineModel.table1506, mach, (correctedThrust / EngineModel.maxThrust));
+
+ const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(predictedN1, mach, midStepAltitude) * 2;
+ const fuelFlow = EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2) * (1 + perfFactorPercent / 100); // in lbs/hour
+
+ fuelBurned = fuelFlow / 3600 * stepTime;
+ previousMidstepWeight = midstepWeight;
+ midstepWeight -= (fuelBurned / 2);
+ } while (++iterations < 4 && Math.abs(previousMidstepWeight - midstepWeight) > 100);
+ return {
+ pathAngle: pathAngle * MathUtils.RADIANS_TO_DEGREES,
+ verticalSpeed,
+ distanceTraveled,
+ fuelBurned,
+ timeElapsed: stepTime,
+ finalAltitude,
+ predictedN1,
+ speed: econCAS,
+ };
+ }
+
+ static verticalSpeedDistanceStep(
+ initialAltitude: number,
+ distance: NauticalMiles,
+ verticalSpeed: number,
+ econCAS: number,
+ econMach: number,
+ zeroFuelWeight: number,
+ initialFuelWeight: number,
+ isaDev: number,
+ headwind: number,
+ perfFactorPercent: number,
+ ): StepResults & { predictedN1: number } {
+ let finalAltitude = initialAltitude;
+ let previousFinalAltitude = finalAltitude;
+
+ let pathAngle: Radians = 0;
+ let stepTime: Seconds = 0;
+ let fuelBurned: Pounds = 0;
+ let iterations = 0;
+ let midstepWeight: Pounds = zeroFuelWeight + initialFuelWeight;
+ let predictedN1 = 0;
+ do {
+ const midStepAltitude = (initialAltitude + finalAltitude) / 2;
+
+ const theta = Common.getTheta(midStepAltitude, isaDev);
+ const delta = Common.getDelta(midStepAltitude);
+
+ let mach = Common.CAStoMach(econCAS, delta);
+ const delta2 = Common.getDelta2(delta, mach);
+ const theta2 = Common.getTheta2(theta, mach);
+
+ let tas: Knots;
+ let accelFactorMode: AccelFactorMode = AccelFactorMode.CONSTANT_CAS;
+ // If above crossover altitude, use econMach
+ if (mach > econMach) {
+ mach = econMach;
+ tas = Common.machToTAS(mach, theta);
+ accelFactorMode = AccelFactorMode.CONSTANT_MACH;
+ } else {
+ tas = Common.CAStoTAS(econCAS, theta, delta);
+ }
+
+ // TODO: Use headwind
+ pathAngle = Math.atan2(verticalSpeed, tas * 101.269); // radians
+ stepTime = (tas - headwind) !== 0 ? 3600 * distance / (tas - headwind) : 0;
+
+ const drag = FlightModel.getDrag(midstepWeight, mach, delta, false, false, FlapConf.CLEAN);
+ const thrust = FlightModel.getThrustFromConstantPathAngle(pathAngle * MathUtils.RADIANS_TO_DEGREES, midstepWeight, drag, accelFactorMode);
+
+ const correctedThrust = (thrust / delta2) / 2;
+ // Since table 1506 describes corrected thrust as a fraction of max thrust, divide it
+ predictedN1 = EngineModel.reverseTableInterpolation(EngineModel.table1506, mach, (correctedThrust / EngineModel.maxThrust));
+
+ const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(predictedN1, mach, midStepAltitude) * 2;
+ const fuelFlow = EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2) * (1 + perfFactorPercent / 100); // in lbs/hour
+
+ previousFinalAltitude = finalAltitude;
+ finalAltitude = initialAltitude + verticalSpeed * stepTime / 60;
+ fuelBurned = fuelFlow / 3600 * stepTime;
+ midstepWeight -= (fuelBurned / 2);
+ } while (++iterations < 4 && Math.abs(previousFinalAltitude - finalAltitude) > 10);
+
+ return {
+ pathAngle: pathAngle * MathUtils.RADIANS_TO_DEGREES,
+ verticalSpeed,
+ distanceTraveled: distance,
+ fuelBurned,
+ timeElapsed: stepTime,
+ initialAltitude,
+ finalAltitude,
+ predictedN1,
+ };
+ }
+
+ static verticalSpeedStepWithSpeedChange(
+ initialAltitude: number,
+ initialCAS: number,
+ finalCAS: number,
+ verticalSpeed: number,
+ econMach: number,
+ commandedN1: number,
+ zeroFuelWeight: number,
+ initialFuelWeight: number,
+ headwindAtMidStepAlt: number,
+ isaDev: number,
+ tropoAltitude: number,
+ speedbrakesExtended = false,
+ flapsConfig: FlapConf = FlapConf.CLEAN,
+ perfFactorPercent: number = 0,
+ ): StepResults {
const weightEstimate = zeroFuelWeight + initialFuelWeight;
- const lift = weightEstimate * Math.cos(fpaRadians);
- const liftCoefficient = FlightModel.getLiftCoefficientFromEAS(lift, eas);
- const dragCoefficient = FlightModel.getDragCoefficient(liftCoefficient);
-
- const accelRate = FlightModel.getAccelRateFromIdleGeoPathCoefficients(
- thrust,
- weightEstimate,
- liftCoefficient,
- dragCoefficient,
- fpaDegrees,
- );
- return accelRate;
+ let pathAngle: Radians;
+ let finalAltitude = initialAltitude;
+ let previousFinalAltitude = finalAltitude;
+ let stepTime: Seconds;
+ let distanceTraveled: NauticalMiles;
+ let fuelBurned: Pounds;
+ let midStepWeight = weightEstimate;
+ let iterations = 0;
+
+ do {
+ const midStepAltitude = (initialAltitude + finalAltitude) / 2;
+ const isAboveTropo = midStepAltitude > tropoAltitude;
+
+ const theta = Common.getTheta(midStepAltitude, isaDev, isAboveTropo);
+ const delta = Common.getDelta(midStepAltitude, isAboveTropo);
+
+ let initialMach = Common.CAStoMach(initialCAS, delta);
+ let finalMach = Common.CAStoMach(finalCAS, delta);
+
+ let initialTas: Knots;
+ let accelFactorMode: AccelFactorMode = AccelFactorMode.CONSTANT_CAS;
+ // If above crossover altitude, use econMach
+ if (initialMach > econMach) {
+ initialMach = econMach;
+ initialTas = Common.machToTAS(initialMach, theta);
+ accelFactorMode = AccelFactorMode.CONSTANT_MACH;
+ } else {
+ initialTas = Common.CAStoTAS(initialCAS, theta, delta);
+ }
+
+ let finalTas: Knots;
+ if (finalMach > econMach) {
+ finalMach = econMach;
+ finalTas = Common.machToTAS(finalMach, theta);
+ } else {
+ finalTas = Common.CAStoTAS(finalCAS, theta, delta);
+ }
+
+ const midwayTas = (initialTas + finalTas) / 2;
+ const midwayMach = (initialMach + finalMach) / 2;
+
+ // Engine model calculations
+ const theta2 = Common.getTheta2(theta, midwayMach);
+ const delta2 = Common.getDelta2(delta, midwayMach);
+ const correctedN1 = EngineModel.getCorrectedN1(commandedN1, theta2);
+ const correctedThrust = EngineModel.tableInterpolation(EngineModel.table1506, correctedN1, midwayMach) * 2 * EngineModel.maxThrust;
+ const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(correctedN1, midwayMach, midStepAltitude) * 2;
+ const thrust = EngineModel.getUncorrectedThrust(correctedThrust, delta2); // in lbf
+ const fuelFlow = Math.max(0, EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2) * (1 + perfFactorPercent / 100)); // in lbs/hour
+
+ const drag = FlightModel.getDrag(midStepWeight, midwayMach, delta, speedbrakesExtended, false, flapsConfig);
+
+ const availableGradient = FlightModel.getAvailableGradient(thrust, drag, midStepWeight);
+ pathAngle = Math.atan2(verticalSpeed, midwayTas * 101.269); // radians
+
+ const accelerationFactor = Common.getAccelerationFactor(midwayMach, midStepAltitude, isaDev, isAboveTropo, accelFactorMode);
+ const acceleration = FlightModel.accelerationForGradient(availableGradient, pathAngle, accelerationFactor) * FlightModel.gravityConstKNS;
+
+ stepTime = (finalCAS - initialCAS) / acceleration; // in seconds
+ distanceTraveled = (midwayTas - headwindAtMidStepAlt) * (stepTime / 3600); // in nautical miles
+ fuelBurned = (fuelFlow / 3600) * stepTime;
+
+ // Adjust variables for better accuracy next iteration
+ previousFinalAltitude = finalAltitude;
+ finalAltitude = initialAltitude + stepTime / 60 * verticalSpeed;
+
+ midStepWeight = zeroFuelWeight + (initialFuelWeight - (fuelBurned / 2));
+ iterations++;
+ } while (iterations < 4 && Math.abs(previousFinalAltitude - finalAltitude) > 10);
+
+ return {
+ pathAngle: pathAngle * MathUtils.RADIANS_TO_DEGREES,
+ verticalSpeed,
+ timeElapsed: stepTime,
+ distanceTraveled,
+ fuelBurned,
+ initialAltitude,
+ finalAltitude,
+ speed: finalCAS,
+ };
+ }
+
+ static altitudeStepWithSpeedChange(
+ initialAltitude: number,
+ initialCAS: number,
+ finalCAS: number,
+ econMach: number,
+ commandedN1: number,
+ zeroFuelWeight: number,
+ initialFuelWeight: number,
+ headwindAtMidStepAlt: number,
+ isaDev: number,
+ tropoAltitude: number,
+ speedbrakesExtended = false,
+ flapsConfig: FlapConf = FlapConf.CLEAN,
+ perfFactorPercent: number = 0,
+ ): StepResults {
+ const weightEstimate = zeroFuelWeight + initialFuelWeight;
+
+ let pathAngle: Radians;
+ let finalAltitude = initialAltitude;
+ let previousFinalAltitude = finalAltitude;
+ let verticalSpeed: FeetPerMinute;
+ let stepTime: Seconds;
+ let distanceTraveled: NauticalMiles;
+ let fuelBurned: Pounds;
+ let midStepWeight = weightEstimate;
+ let iterations = 0;
+
+ do {
+ const midStepAltitude = (initialAltitude + finalAltitude) / 2;
+ const isAboveTropo = midStepAltitude > tropoAltitude;
+
+ const theta = Common.getTheta(midStepAltitude, isaDev, isAboveTropo);
+ const delta = Common.getDelta(midStepAltitude, isAboveTropo);
+
+ let initialMach = Common.CAStoMach(initialCAS, delta);
+ let finalMach = Common.CAStoMach(finalCAS, delta);
+
+ let initialTas: Knots;
+ let accelFactorMode: AccelFactorMode = AccelFactorMode.CONSTANT_CAS;
+ // If above crossover altitude, use econMach
+ if (initialMach > econMach) {
+ initialMach = econMach;
+ initialTas = Common.machToTAS(initialMach, theta);
+ accelFactorMode = AccelFactorMode.CONSTANT_MACH;
+ } else {
+ initialTas = Common.CAStoTAS(initialCAS, theta, delta);
+ }
+
+ let finalTas: Knots;
+ // If above crossover altitude, use econMach
+ if (finalMach > econMach) {
+ finalMach = econMach;
+ finalTas = Common.machToTAS(finalMach, theta);
+ } else {
+ finalTas = Common.CAStoTAS(finalCAS, theta, delta);
+ }
+
+ const midwayMach = (initialMach + finalMach) / 2;
+ const midwayTas = (initialTas + finalTas) / 2;
+
+ // Engine model calculations
+ const theta2 = Common.getTheta2(theta, midwayMach);
+ const delta2 = Common.getDelta2(delta, midwayMach);
+ const correctedN1 = EngineModel.getCorrectedN1(commandedN1, theta2);
+ const correctedThrust = EngineModel.tableInterpolation(EngineModel.table1506, correctedN1, midwayMach) * 2 * EngineModel.maxThrust;
+ const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(correctedN1, midwayMach, midStepAltitude) * 2;
+ const thrust = EngineModel.getUncorrectedThrust(correctedThrust, delta2); // in lbf
+ const fuelFlow = Math.max(0, EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2) * (1 + perfFactorPercent / 100)); // in lbs/hour
+
+ const drag = FlightModel.getDrag(midStepWeight, midwayMach, delta, speedbrakesExtended, false, flapsConfig);
+
+ const availableGradient = FlightModel.getAvailableGradient(thrust, drag, midStepWeight);
+ pathAngle = FlightModel.getSpeedChangePathAngle(thrust, midStepWeight, drag); // radians
+ const accelerationFactor = Common.getAccelerationFactor(midwayMach, midStepAltitude, isaDev, isAboveTropo, accelFactorMode);
+ const acceleration = FlightModel.accelerationForGradient(availableGradient, pathAngle, accelerationFactor) * FlightModel.gravityConstKNS;
+
+ verticalSpeed = 101.268 * midwayTas * Math.sin(pathAngle); // in feet per minute
+ stepTime = (finalCAS - initialCAS) / acceleration; // in seconds
+ distanceTraveled = (midwayTas - headwindAtMidStepAlt) * (stepTime / 3600); // in nautical miles
+ fuelBurned = (fuelFlow / 3600) * stepTime;
+
+ // Adjust variables for better accuracy next iteration
+ previousFinalAltitude = finalAltitude;
+ finalAltitude = initialAltitude + stepTime / 60 * verticalSpeed;
+
+ midStepWeight = zeroFuelWeight + (initialFuelWeight - (fuelBurned / 2));
+ iterations++;
+ } while (iterations < 4 && Math.abs(previousFinalAltitude - finalAltitude) > 10);
+
+ return {
+ pathAngle: pathAngle * MathUtils.RADIANS_TO_DEGREES,
+ verticalSpeed,
+ timeElapsed: stepTime,
+ distanceTraveled,
+ fuelBurned,
+ initialAltitude,
+ finalAltitude,
+ speed: finalCAS,
+ };
+ }
+
+ static reverseAltitudeStepWithSpeedChange(
+ finalAltitude: number,
+ initialCAS: number,
+ finalCAS: number,
+ econMach: number,
+ commandedN1: number,
+ zeroFuelWeight: number,
+ initialFuelWeight: number,
+ headwindAtMidStepAlt: number,
+ isaDev: number,
+ tropoAltitude: number,
+ speedbrakesExtended = false,
+ flapsConfig: FlapConf = FlapConf.CLEAN,
+ perfFactorPercent: number = 0,
+ ): StepResults {
+ const weightEstimate = zeroFuelWeight + initialFuelWeight;
+
+ let pathAngle: Radians;
+ let initialAltitude = finalAltitude;
+ let previousInitialAltitude = finalAltitude;
+ let verticalSpeed: FeetPerMinute;
+ let stepTime: Seconds;
+ let distanceTraveled: NauticalMiles;
+ let fuelBurned: Pounds;
+ let midStepWeight = weightEstimate;
+ let iterations = 0;
+
+ do {
+ const midStepAltitude = (initialAltitude + finalAltitude) / 2;
+ const isAboveTropo = midStepAltitude > tropoAltitude;
+
+ const theta = Common.getTheta(midStepAltitude, isaDev, isAboveTropo);
+ const delta = Common.getDelta(midStepAltitude, isAboveTropo);
+
+ let initialMach = Common.CAStoMach(initialCAS, delta);
+ let finalMach = Common.CAStoMach(finalCAS, delta);
+
+ let initialTas: Knots;
+ let accelFactorMode: AccelFactorMode = AccelFactorMode.CONSTANT_CAS;
+ // If above crossover altitude, use econMach
+ if (initialMach > econMach) {
+ initialMach = econMach;
+ initialTas = Common.machToTAS(initialMach, theta);
+ accelFactorMode = AccelFactorMode.CONSTANT_MACH;
+ } else {
+ initialTas = Common.CAStoTAS(initialCAS, theta, delta);
+ }
+
+ let finalTas: Knots;
+ // If above crossover altitude, use econMach
+ if (finalMach > econMach) {
+ finalMach = econMach;
+ finalTas = Common.machToTAS(finalMach, theta);
+ } else {
+ finalTas = Common.CAStoTAS(finalCAS, theta, delta);
+ }
+
+ const midwayMach = (initialMach + finalMach) / 2;
+ const midwayTas = (initialTas + finalTas) / 2;
+
+ // Engine model calculations
+ const theta2 = Common.getTheta2(theta, midwayMach);
+ const delta2 = Common.getDelta2(delta, midwayMach);
+ const correctedN1 = EngineModel.getCorrectedN1(commandedN1, theta2);
+ const correctedThrust = EngineModel.tableInterpolation(EngineModel.table1506, correctedN1, midwayMach) * 2 * EngineModel.maxThrust;
+ const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(correctedN1, midwayMach, midStepAltitude) * 2;
+ const thrust = EngineModel.getUncorrectedThrust(correctedThrust, delta2); // in lbf
+ const fuelFlow = Math.max(0, EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2) * (1 + perfFactorPercent / 100)); // in lbs/hour
+
+ const drag = FlightModel.getDrag(midStepWeight, midwayMach, delta, speedbrakesExtended, false, flapsConfig);
+
+ const availableGradient = FlightModel.getAvailableGradient(thrust, drag, midStepWeight);
+
+ // This is based on a reference saying that the energy loss should go into deceleration by 70% and 30% for altitude loss.
+ // TODO: Using the gradient for this probably doesn't make too much sense.
+ pathAngle = availableGradient * 0.3;
+
+ const accelerationFactor = Common.getAccelerationFactor(midwayMach, midStepAltitude, isaDev, isAboveTropo, accelFactorMode);
+ const acceleration = FlightModel.accelerationForGradient(availableGradient, pathAngle, accelerationFactor) * FlightModel.gravityConstKNS;
+
+ verticalSpeed = 101.268 * midwayTas * Math.sin(pathAngle); // in feet per minute
+ stepTime = (finalCAS - initialCAS) / acceleration; // in seconds
+ distanceTraveled = (midwayTas - headwindAtMidStepAlt) * (stepTime / 3600); // in nautical miles
+ fuelBurned = (fuelFlow / 3600) * stepTime;
+
+ // Adjust variables for better accuracy next iteration
+ previousInitialAltitude = initialAltitude;
+ initialAltitude = finalAltitude - stepTime / 60 * verticalSpeed;
+
+ midStepWeight = zeroFuelWeight + (initialFuelWeight - (fuelBurned / 2));
+ iterations++;
+ } while (iterations < 4 && Math.abs(previousInitialAltitude - initialAltitude) > 10);
+
+ return {
+ pathAngle: pathAngle * MathUtils.RADIANS_TO_DEGREES,
+ verticalSpeed,
+ timeElapsed: stepTime,
+ distanceTraveled,
+ fuelBurned,
+ initialAltitude,
+ finalAltitude,
+ speed: initialCAS,
+ };
}
}
diff --git a/src/fmgc/src/guidance/vnav/StepCoordinator.ts b/src/fmgc/src/guidance/vnav/StepCoordinator.ts
new file mode 100644
index 000000000000..06392145afe6
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/StepCoordinator.ts
@@ -0,0 +1,100 @@
+import { NavGeometryProfile, VerticalCheckpointReason } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { FlightPlanManager } from '@fmgc/wtsdk';
+
+export interface Step {
+ ident: string,
+ toAltitude: Feet,
+ location: LatLongAlt,
+ isIgnored: boolean,
+ get distanceFromStart(): NauticalMiles
+}
+
+class GeographicStep implements Step {
+ constructor(private waypoint: WayPoint, public waypointIndex: number, public toAltitude: Feet, public isIgnored: boolean) {}
+
+ get ident(): string {
+ return this.waypoint.ident;
+ }
+
+ get location(): LatLongAlt {
+ return this.waypoint.infos.coordinates;
+ }
+
+ get distanceFromStart(): NauticalMiles {
+ return this.waypoint.cumulativeDistanceInFP;
+ }
+}
+
+export class StepCoordinator {
+ private currentNavGeometryProfile?: NavGeometryProfile;
+
+ steps: Step[] = [];
+
+ constructor(private flightPlanManager: FlightPlanManager) {}
+
+ requestToAddGeographicStep(waypointIdent: string, toAltitude: Feet): boolean {
+ const [index, waypoint] = this.findWaypoint(waypointIdent);
+
+ if (!waypoint) {
+ return false;
+ }
+
+ const topOfDescent = this.currentNavGeometryProfile.findLastVerticalCheckpoint(VerticalCheckpointReason.TopOfDescent);
+ const waypointPrediction = this.currentNavGeometryProfile.waypointPredictions.get(index);
+
+ let isIgnored = false;
+ if (topOfDescent && waypointPrediction) {
+ if (topOfDescent.distanceFromStart - waypointPrediction.distanceFromStart < 50) {
+ isIgnored = true;
+ }
+ }
+
+ this.insertStep(new GeographicStep(waypoint, index, toAltitude, isIgnored));
+
+ return true;
+ }
+
+ requestToAddOptimalStep(): boolean {
+ return false;
+ }
+
+ removeStep(index: number) {
+ this.steps.splice(index, 1);
+ }
+
+ insertStep(step: Step) {
+ if (this.steps.length <= 0 || step.distanceFromStart < this.steps[0].distanceFromStart) {
+ this.steps.unshift(step);
+ return;
+ }
+
+ for (let i = 0; i < this.steps.length - 1; i++) {
+ if (step.distanceFromStart >= this.steps[i].distanceFromStart && step.distanceFromStart < this.steps[i + 1].distanceFromStart) {
+ this.steps.splice(i + 1, 0, step);
+ return;
+ }
+ }
+
+ this.steps.push(step);
+ }
+
+ private findWaypoint(ident: string): [number, WayPoint | undefined] {
+ for (let i = 0; i < this.flightPlanManager.getWaypointsCount(); i++) {
+ const waypoint = this.flightPlanManager.getWaypoint(i);
+
+ if (!waypoint) {
+ continue;
+ }
+
+ if (this.flightPlanManager.getWaypoint(i).ident === ident) {
+ return [i, waypoint];
+ }
+ }
+
+ return [-1, undefined];
+ }
+
+ updateGeometryProfile(newProfile: NavGeometryProfile) {
+ this.currentNavGeometryProfile = newProfile;
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/VerticalProfileComputationParameters.ts b/src/fmgc/src/guidance/vnav/VerticalProfileComputationParameters.ts
new file mode 100644
index 000000000000..11221014f06c
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/VerticalProfileComputationParameters.ts
@@ -0,0 +1,125 @@
+import { Fmgc } from '@fmgc/guidance/GuidanceController';
+import { FlapConf } from '@fmgc/guidance/vnav/common';
+import { SpeedLimit } from '@fmgc/guidance/vnav/SpeedLimit';
+import { ArmedLateralMode, ArmedVerticalMode, LateralMode, VerticalMode } from '@shared/autopilot';
+import { Constants } from '@shared/Constants';
+import { FmgcFlightPhase } from '@shared/flightphase';
+
+export interface VerticalProfileComputationParameters {
+ presentPosition: LatLongAlt,
+
+ fcuAltitude: Feet,
+ fcuVerticalMode: VerticalMode,
+ fcuLateralMode: LateralMode,
+ fcuVerticalSpeed: FeetPerMinute,
+ fcuFlightPathAngle: Degrees,
+ fcuSpeed: Knots | Mach,
+ fcuArmedLateralMode: ArmedLateralMode,
+ fcuArmedVerticalMode: ArmedVerticalMode,
+ qnhSettingMillibar: Millibar,
+
+ managedCruiseSpeed: Knots,
+ managedCruiseSpeedMach: Mach,
+
+ zeroFuelWeight: Pounds,
+ fuelOnBoard: Pounds,
+ v2Speed: Knots,
+ tropoPause: Feet,
+ managedClimbSpeed: Knots,
+ managedClimbSpeedMach: Mach,
+ perfFactor: number,
+ originAirfieldElevation: Feet,
+ destinationAirfieldElevation: Feet,
+ accelerationAltitude: Feet,
+ thrustReductionAltitude: Feet,
+ cruiseAltitude: Feet,
+ climbSpeedLimit: SpeedLimit,
+ descentSpeedLimit: SpeedLimit,
+ flightPhase: FmgcFlightPhase,
+ preselectedClbSpeed: Knots,
+ takeoffFlapsSetting?: FlapConf
+
+ managedDescentSpeed: Knots,
+ managedDescentSpeedMach: Mach,
+
+ approachSpeed: Knots,
+ flapRetractionSpeed: Knots,
+ slatRetractionSpeed: Knots,
+ cleanSpeed: Knots,
+}
+
+export class VerticalProfileComputationParametersObserver {
+ private parameters: VerticalProfileComputationParameters;
+
+ constructor(private fmgc: Fmgc) {
+ this.update();
+ }
+
+ update() {
+ this.parameters = {
+ presentPosition: this.getPresentPosition(),
+
+ fcuAltitude: Simplane.getAutoPilotDisplayedAltitudeLockValue(),
+ fcuVerticalMode: SimVar.GetSimVarValue('L:A32NX_FMA_VERTICAL_MODE', 'Enum'),
+ fcuLateralMode: SimVar.GetSimVarValue('L:A32NX_FMA_LATERAL_MODE', 'Enum'),
+ fcuVerticalSpeed: SimVar.GetSimVarValue('L:A32NX_AUTOPILOT_VS_SELECTED', 'Feet per minute'),
+ fcuFlightPathAngle: SimVar.GetSimVarValue('L:A32NX_AUTOPILOT_FPA_SELECTED', 'Degrees'),
+ fcuSpeed: SimVar.GetSimVarValue('L:A32NX_AUTOPILOT_SPEED_SELECTED', 'number'),
+ fcuArmedLateralMode: SimVar.GetSimVarValue('L:A32NX_FMA_LATERAL_ARMED', 'number'),
+ fcuArmedVerticalMode: SimVar.GetSimVarValue('L:A32NX_FMA_VERTICAL_ARMED', 'number'),
+ qnhSettingMillibar: Simplane.getPressureValue('millibar'),
+
+ managedCruiseSpeed: this.fmgc.getManagedCruiseSpeed(),
+ managedCruiseSpeedMach: this.fmgc.getManagedCruiseSpeedMach(),
+
+ zeroFuelWeight: this.fmgc.getZeroFuelWeight(),
+ fuelOnBoard: this.fmgc.getFOB() * Constants.TONS_TO_POUNDS,
+ v2Speed: this.fmgc.getV2Speed(),
+ tropoPause: this.fmgc.getTropoPause(),
+ managedClimbSpeed: this.fmgc.getManagedClimbSpeed(),
+ managedClimbSpeedMach: this.fmgc.getManagedClimbSpeedMach(),
+ perfFactor: 0, // FIXME: Use actual value,
+ originAirfieldElevation: SimVar.GetSimVarValue('L:A32NX_DEPARTURE_ELEVATION', 'feet'),
+ destinationAirfieldElevation: SimVar.GetSimVarValue('L:A32NX_PRESS_AUTO_LANDING_ELEVATION', 'feet'),
+ accelerationAltitude: this.fmgc.getAccelerationAltitude(),
+ thrustReductionAltitude: this.fmgc.getThrustReductionAltitude(),
+ cruiseAltitude: Number.isFinite(this.fmgc.getCruiseAltitude()) ? this.fmgc.getCruiseAltitude() : this.parameters.cruiseAltitude,
+ climbSpeedLimit: this.fmgc.getClimbSpeedLimit(),
+ descentSpeedLimit: this.fmgc.getDescentSpeedLimit(),
+ flightPhase: this.fmgc.getFlightPhase(),
+ preselectedClbSpeed: this.fmgc.getPreSelectedClbSpeed(),
+ takeoffFlapsSetting: this.fmgc.getTakeoffFlapsSetting(),
+
+ managedDescentSpeed: this.fmgc.getManagedDescentSpeed(),
+ managedDescentSpeedMach: this.fmgc.getManagedDescentSpeedMach(),
+
+ approachSpeed: this.fmgc.getApproachSpeed(),
+ flapRetractionSpeed: this.fmgc.getFlapRetractionSpeed(),
+ slatRetractionSpeed: this.fmgc.getSlatRetractionSpeed(),
+ cleanSpeed: this.fmgc.getCleanSpeed(),
+ };
+ }
+
+ getPresentPosition(): LatLongAlt {
+ return new LatLongAlt(
+ SimVar.GetSimVarValue('PLANE LATITUDE', 'degree latitude'),
+ SimVar.GetSimVarValue('PLANE LONGITUDE', 'degree longitude'),
+ SimVar.GetSimVarValue('INDICATED ALTITUDE', 'feet'),
+ );
+ }
+
+ get(): VerticalProfileComputationParameters {
+ return this.parameters;
+ }
+
+ canComputeProfile(): boolean {
+ const areApproachSpeedsValid = this.parameters.cleanSpeed > 100
+ && this.parameters.slatRetractionSpeed > 100
+ && this.parameters.flapRetractionSpeed > 100
+ && this.parameters.approachSpeed > 100;
+
+ const hasZeroFuelWeight = Number.isFinite(this.parameters.zeroFuelWeight);
+
+ return (this.parameters.flightPhase > FmgcFlightPhase.Takeoff || this.parameters.v2Speed > 0) && areApproachSpeedsValid && hasZeroFuelWeight;
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/VnavConfig.ts b/src/fmgc/src/guidance/vnav/VnavConfig.ts
index 6332c3ea7ff8..5a2430fc535b 100644
--- a/src/fmgc/src/guidance/vnav/VnavConfig.ts
+++ b/src/fmgc/src/guidance/vnav/VnavConfig.ts
@@ -12,28 +12,21 @@ export enum VnavDescentMode {
export const VnavConfig = {
/**
- * Whether to calculate climb profile
+ * VNAV descent calculation mode (NORMAL, CDA or DPO)
*/
- VNAV_CALCULATE_CLIMB_PROFILE: false,
+ VNAV_DESCENT_MODE: VnavDescentMode.NORMAL,
/**
- * Whether to emit ToD pseudo waypoint
+ * Whether to emit CDA flap1/2 pseudo-waypoints (only if VNAV_DESCENT_MODE is CDA)
*/
- VNAV_EMIT_TOD: false,
+ VNAV_EMIT_CDA_FLAP_PWP: false,
- /**
- * Whether to emit (DECEL) pseudo waypoint
- */
- VNAV_EMIT_DECEL: true,
+ DEBUG_PROFILE: false,
- /**
- * VNAV descent calculation mode (NORMAL, CDA or DPO)
- */
- VNAV_DESCENT_MODE: VnavDescentMode.CDA,
+ VNAV_USE_LATCHED_DESCENT_MODE: false,
/**
- * Whether to emit CDA flap1/2 pseudo-waypoints (only if VNAV_DESCENT_MODE is CDA)
+ * Percent N1 to add to the predicted idle N1. The real aircraft does also use a margin for this, but I don't know how much
*/
- VNAV_EMIT_CDA_FLAP_PWP: false,
-
+ IDLE_N1_MARGIN: 3,
};
diff --git a/src/fmgc/src/guidance/vnav/VnavDriver.ts b/src/fmgc/src/guidance/vnav/VnavDriver.ts
index ea0fdebefcd9..c8968155d644 100644
--- a/src/fmgc/src/guidance/vnav/VnavDriver.ts
+++ b/src/fmgc/src/guidance/vnav/VnavDriver.ts
@@ -1,26 +1,67 @@
// Copyright (c) 2021 FlyByWire Simulations
// SPDX-License-Identifier: GPL-3.0
-import { TheoreticalDescentPathCharacteristics } from '@fmgc/guidance/vnav/descent/TheoreticalDescentPath';
import { DecelPathBuilder, DecelPathCharacteristics } from '@fmgc/guidance/vnav/descent/DecelPathBuilder';
-import { DescentBuilder } from '@fmgc/guidance/vnav/descent/DescentBuilder';
-import { VnavConfig } from '@fmgc/guidance/vnav/VnavConfig';
+import { DescentPathBuilder } from '@fmgc/guidance/vnav/descent/DescentPathBuilder';
import { GuidanceController } from '@fmgc/guidance/GuidanceController';
import { RequestedVerticalMode, TargetAltitude, TargetVerticalSpeed } from '@fmgc/guidance/ControlLaws';
import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions';
-import { VerticalMode } from '@shared/autopilot';
import { CoarsePredictions } from '@fmgc/guidance/vnav/CoarsePredictions';
+import { VerticalMode, ArmedLateralMode, ArmedVerticalMode, isArmed, LateralMode } from '@shared/autopilot';
+import { FlightPlanManager } from '@fmgc/flightplanning/FlightPlanManager';
+import { PseudoWaypointFlightPlanInfo } from '@fmgc/guidance/PseudoWaypoint';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { CruisePathBuilder } from '@fmgc/guidance/vnav/cruise/CruisePathBuilder';
+import { CruiseToDescentCoordinator } from '@fmgc/guidance/vnav/CruiseToDescentCoordinator';
+import { VnavConfig } from '@fmgc/guidance/vnav/VnavConfig';
+import { McduSpeedProfile, ExpediteSpeedProfile, NdSpeedProfile } from '@fmgc/guidance/vnav/climb/SpeedProfile';
+import { SelectedGeometryProfile } from '@fmgc/guidance/vnav/profile/SelectedGeometryProfile';
+import { BaseGeometryProfile } from '@fmgc/guidance/vnav/profile/BaseGeometryProfile';
+import { StepCoordinator } from '@fmgc/guidance/vnav/StepCoordinator';
+import { TakeoffPathBuilder } from '@fmgc/guidance/vnav/takeoff/TakeoffPathBuilder';
+import { ClimbThrustClimbStrategy, VerticalSpeedStrategy } from '@fmgc/guidance/vnav/climb/ClimbStrategy';
+import { ConstraintReader } from '@fmgc/guidance/vnav/ConstraintReader';
+import { FmgcFlightPhase } from '@shared/flightphase';
+import { TacticalDescentPathBuilder } from '@fmgc/guidance/vnav/descent/TacticalDescentPathBuilder';
+import { IdleDescentStrategy } from '@fmgc/guidance/vnav/descent/DescentStrategy';
+import { LatchedDescentGuidance } from '@fmgc/guidance/vnav/descent/LatchedDescentGuidance';
+import { DescentGuidance } from '@fmgc/guidance/vnav/descent/DescentGuidance';
+import { ProfileInterceptCalculator } from '@fmgc/guidance/vnav/descent/ProfileInterceptCalculator';
+import { ApproachPathBuilder } from '@fmgc/guidance/vnav/descent/ApproachPathBuilder';
+import { FlapConf } from '@fmgc/guidance/vnav/common';
+import { AircraftToDescentProfileRelation } from '@fmgc/guidance/vnav/descent/AircraftToProfileRelation';
+import { WindProfileFactory } from '@fmgc/guidance/vnav/wind/WindProfileFactory';
+import { NavHeadingProfile } from '@fmgc/guidance/vnav/wind/AircraftHeadingProfile';
+import { HeadwindProfile } from '@fmgc/guidance/vnav/wind/HeadwindProfile';
import { Geometry } from '../Geometry';
import { GuidanceComponent } from '../GuidanceComponent';
+import { NavGeometryProfile, VerticalCheckpointReason } from './profile/NavGeometryProfile';
import { ClimbPathBuilder } from './climb/ClimbPathBuilder';
-import { ClimbProfileBuilderResult } from './climb/ClimbProfileBuilderResult';
export class VnavDriver implements GuidanceComponent {
- atmosphericConditions: AtmosphericConditions = new AtmosphericConditions();
+ version: number = 0;
+
+ takeoffPathBuilder: TakeoffPathBuilder;
+
+ climbPathBuilder: ClimbPathBuilder;
+
+ cruisePathBuilder: CruisePathBuilder;
+
+ tacticalDescentPathBuilder: TacticalDescentPathBuilder;
+
+ managedDescentPathBuilder: DescentPathBuilder;
+
+ decelPathBuilder: DecelPathBuilder;
- currentClimbProfile: ClimbProfileBuilderResult;
+ approachPathBuilder: ApproachPathBuilder;
- currentDescentProfile: TheoreticalDescentPathCharacteristics
+ cruiseToDescentCoordinator: CruiseToDescentCoordinator;
+
+ currentNavGeometryProfile: NavGeometryProfile;
+
+ currentSelectedGeometryProfile?: SelectedGeometryProfile;
+
+ currentNdGeometryProfile?: BaseGeometryProfile;
currentApproachProfile: DecelPathCharacteristics;
@@ -33,9 +74,47 @@ export class VnavDriver implements GuidanceComponent {
// eslint-disable-next-line camelcase
private coarsePredictionsUpdate = new A32NX_Util.UpdateThrottler(5000);
+ currentMcduSpeedProfile: McduSpeedProfile;
+
+ timeMarkers = new Map()
+
+ stepCoordinator: StepCoordinator;
+
+ private constraintReader: ConstraintReader;
+
+ private aircraftToDescentProfileRelation: AircraftToDescentProfileRelation;
+
+ private descentGuidance: DescentGuidance | LatchedDescentGuidance;
+
+ private headingProfile: NavHeadingProfile;
+
constructor(
private readonly guidanceController: GuidanceController,
+ private readonly computationParametersObserver: VerticalProfileComputationParametersObserver,
+ private readonly atmosphericConditions: AtmosphericConditions,
+ private readonly windProfileFactory: WindProfileFactory,
+ private readonly flightPlanManager: FlightPlanManager,
) {
+ this.headingProfile = new NavHeadingProfile(flightPlanManager);
+ this.currentMcduSpeedProfile = new McduSpeedProfile(this.computationParametersObserver, 0, [], []);
+
+ this.takeoffPathBuilder = new TakeoffPathBuilder(computationParametersObserver, this.atmosphericConditions);
+ this.climbPathBuilder = new ClimbPathBuilder(computationParametersObserver, this.atmosphericConditions);
+ this.stepCoordinator = new StepCoordinator(this.flightPlanManager);
+ this.cruisePathBuilder = new CruisePathBuilder(computationParametersObserver, this.atmosphericConditions, this.stepCoordinator);
+ this.tacticalDescentPathBuilder = new TacticalDescentPathBuilder(this.computationParametersObserver);
+ this.managedDescentPathBuilder = new DescentPathBuilder(computationParametersObserver, this.atmosphericConditions);
+ // TODO: Remove decelPathBuilder
+ this.decelPathBuilder = new DecelPathBuilder(computationParametersObserver, this.atmosphericConditions);
+ this.approachPathBuilder = new ApproachPathBuilder(computationParametersObserver, this.atmosphericConditions);
+ this.cruiseToDescentCoordinator = new CruiseToDescentCoordinator(computationParametersObserver, this.cruisePathBuilder, this.managedDescentPathBuilder, this.approachPathBuilder);
+
+ this.constraintReader = new ConstraintReader(this.flightPlanManager);
+
+ this.aircraftToDescentProfileRelation = new AircraftToDescentProfileRelation(this.computationParametersObserver);
+ this.descentGuidance = VnavConfig.VNAV_USE_LATCHED_DESCENT_MODE
+ ? new LatchedDescentGuidance(this.aircraftToDescentProfileRelation, computationParametersObserver, this.atmosphericConditions)
+ : new DescentGuidance(this.aircraftToDescentProfileRelation, computationParametersObserver, this.atmosphericConditions);
}
init(): void {
@@ -43,44 +122,325 @@ export class VnavDriver implements GuidanceComponent {
}
acceptMultipleLegGeometry(geometry: Geometry) {
- this.computeVerticalProfile(geometry);
+ this.constraintReader.extract(geometry, this.guidanceController.activeLegIndex, this.guidanceController.activeTransIndex, this.computationParametersObserver.get().presentPosition);
+ this.headingProfile.updateGeometry(this.guidanceController.activeGeometry);
+
+ this.computeVerticalProfileForMcdu(geometry);
+ this.computeVerticalProfileForNd(geometry);
+
+ this.stepCoordinator.updateGeometryProfile(this.currentNavGeometryProfile);
+ this.descentGuidance.updateProfile(this.currentNavGeometryProfile);
+ this.guidanceController.pseudoWaypoints.acceptVerticalProfile();
+
+ this.version++;
}
lastCruiseAltitude: Feet = 0;
update(deltaTime: number): void {
- this.atmosphericConditions.update();
+ try {
+ this.atmosphericConditions.update();
- if (this.coarsePredictionsUpdate.canUpdate(deltaTime) !== -1) {
- CoarsePredictions.updatePredictions(this.guidanceController, this.atmosphericConditions);
- }
+ if (this.coarsePredictionsUpdate.canUpdate(deltaTime) !== -1) {
+ CoarsePredictions.updatePredictions(this.guidanceController, this.atmosphericConditions);
+ }
+
+ const newCruiseAltitude = SimVar.GetSimVarValue('L:AIRLINER_CRUISE_ALTITUDE', 'number');
+
+ if (newCruiseAltitude !== this.lastCruiseAltitude) {
+ this.lastCruiseAltitude = newCruiseAltitude;
+
+ if (DEBUG) {
+ console.log('[FMS/VNAV] Computed new vertical profile because of new cruise altitude.');
+ }
+
+ this.constraintReader.extract(
+ this.guidanceController.activeGeometry,
+ this.guidanceController.activeLegIndex,
+ this.guidanceController.activeTransIndex,
+ this.computationParametersObserver.get().presentPosition,
+ );
+ this.windProfileFactory.updateAircraftDistanceFromStart(this.constraintReader.distanceToPresentPosition);
+
+ this.computeVerticalProfileForMcdu(this.guidanceController.activeGeometry);
+ this.computeVerticalProfileForNd(this.guidanceController.activeGeometry);
- const newCruiseAltitude = SimVar.GetSimVarValue('L:AIRLINER_CRUISE_ALTITUDE', 'number');
- if (newCruiseAltitude !== this.lastCruiseAltitude) {
- this.lastCruiseAltitude = newCruiseAltitude;
+ this.stepCoordinator.updateGeometryProfile(this.currentNavGeometryProfile);
+ this.descentGuidance.updateProfile(this.currentNavGeometryProfile);
+ this.guidanceController.pseudoWaypoints.acceptVerticalProfile();
- if (DEBUG) {
- console.log('[FMS/VNAV] Computed new vertical profile because of new cruise altitude.');
+ this.version++;
}
- this.computeVerticalProfile(this.guidanceController.activeGeometry);
+ this.updateTimeMarkers();
+ this.descentGuidance.update();
+ } catch (e) {
+ console.error('[FMS] Failed to calculate vertical profil. See exception below.');
+ console.error(e);
+ }
+ }
+
+ private updateTimeMarkers() {
+ if (!this.currentNavGeometryProfile.isReadyToDisplay) {
+ return;
+ }
+
+ for (const [time] of this.timeMarkers.entries()) {
+ const prediction = this.currentNavGeometryProfile.predictAtTime(time);
+
+ this.timeMarkers.set(time, prediction);
}
this.updateGuidance();
}
- private computeVerticalProfile(geometry: Geometry) {
- if (geometry.legs.size > 0) {
- if (VnavConfig.VNAV_CALCULATE_CLIMB_PROFILE) {
- this.currentClimbProfile = ClimbPathBuilder.computeClimbPath(geometry);
+ /**
+ * Based on the last checkpoint in the profile, we build a profile to the destination
+ * @param geometry
+ */
+ private finishProfileInManagedModes(profile: BaseGeometryProfile, fromFlightPhase: FmgcFlightPhase) {
+ const { cruiseAltitude } = this.computationParametersObserver.get();
+
+ const managedClimbStrategy = new ClimbThrustClimbStrategy(this.computationParametersObserver, this.atmosphericConditions);
+ const stepDescentStrategy = new VerticalSpeedStrategy(this.computationParametersObserver, this.atmosphericConditions, -1000);
+
+ const climbWinds = new HeadwindProfile(this.windProfileFactory.getClimbWinds(), this.headingProfile);
+ const cruiseWinds = new HeadwindProfile(this.windProfileFactory.getCruiseWinds(), this.headingProfile);
+ const descentWinds = new HeadwindProfile(this.windProfileFactory.getDescentWinds(), this.headingProfile);
+
+ if (fromFlightPhase < FmgcFlightPhase.Climb) {
+ this.takeoffPathBuilder.buildTakeoffPath(profile);
+ }
+
+ this.currentMcduSpeedProfile = new McduSpeedProfile(
+ this.computationParametersObserver,
+ this.currentNavGeometryProfile.distanceToPresentPosition,
+ this.currentNavGeometryProfile.maxClimbSpeedConstraints,
+ this.currentNavGeometryProfile.descentSpeedConstraints,
+ );
+
+ if (fromFlightPhase < FmgcFlightPhase.Cruise) {
+ this.climbPathBuilder.computeClimbPath(profile, managedClimbStrategy, this.currentMcduSpeedProfile, climbWinds, cruiseAltitude);
+ }
+
+ if (profile instanceof NavGeometryProfile && this.cruiseToDescentCoordinator.canCompute(profile)) {
+ this.cruiseToDescentCoordinator.buildCruiseAndDescentPath(profile, this.currentMcduSpeedProfile, cruiseWinds, descentWinds, managedClimbStrategy, stepDescentStrategy);
+
+ if (this.currentMcduSpeedProfile.shouldTakeSpeedLimitIntoAccount()) {
+ this.cruiseToDescentCoordinator.addSpeedLimitAsCheckpoint(profile);
+ }
+ }
+ }
+
+ private computeVerticalProfileForMcdu(geometry: Geometry) {
+ const { flightPhase, presentPosition, fuelOnBoard } = this.computationParametersObserver.get();
+
+ this.currentNavGeometryProfile = new NavGeometryProfile(geometry, this.constraintReader, this.atmosphericConditions, this.flightPlanManager.getWaypointsCount());
+
+ if (geometry.legs.size <= 0 || !this.computationParametersObserver.canComputeProfile()) {
+ return;
+ }
+
+ console.time('VNAV computation');
+ // TODO: This is where the return to trajectory would go:
+ if (flightPhase >= FmgcFlightPhase.Climb) {
+ this.currentNavGeometryProfile.addPresentPositionCheckpoint(
+ presentPosition,
+ fuelOnBoard,
+ );
+ }
+
+ this.finishProfileInManagedModes(this.currentNavGeometryProfile, Math.max(FmgcFlightPhase.Takeoff, flightPhase));
+
+ this.currentNavGeometryProfile.finalizeProfile();
+
+ console.timeEnd('VNAV computation');
+
+ if (VnavConfig.DEBUG_PROFILE) {
+ console.log('this.currentNavGeometryProfile:', this.currentNavGeometryProfile);
+ this.currentMcduSpeedProfile.showDebugStats();
+ }
+ }
+
+ private computeVerticalProfileForNd(geometry: Geometry) {
+ const { fcuAltitude, fcuVerticalMode, presentPosition, fuelOnBoard, fcuVerticalSpeed, flightPhase } = this.computationParametersObserver.get();
+
+ this.currentNdGeometryProfile = this.isInManagedNav()
+ ? new NavGeometryProfile(geometry, this.constraintReader, this.atmosphericConditions, this.flightPlanManager.getWaypointsCount())
+ : new SelectedGeometryProfile();
+
+ if (!this.computationParametersObserver.canComputeProfile()) {
+ return;
+ }
+
+ if (flightPhase >= FmgcFlightPhase.Climb) {
+ this.currentNdGeometryProfile.addPresentPositionCheckpoint(
+ presentPosition,
+ fuelOnBoard,
+ );
+ } else {
+ this.takeoffPathBuilder.buildTakeoffPath(this.currentNdGeometryProfile);
+ }
+
+ if (!this.shouldObeyAltitudeConstraints()) {
+ this.currentNdGeometryProfile.resetAltitudeConstraints();
+ }
+
+ const speedProfile = this.shouldObeySpeedConstraints()
+ ? this.currentMcduSpeedProfile
+ : new NdSpeedProfile(
+ this.computationParametersObserver,
+ this.currentNdGeometryProfile.distanceToPresentPosition,
+ this.currentNdGeometryProfile.maxClimbSpeedConstraints,
+ this.currentNdGeometryProfile.descentSpeedConstraints,
+ );
+
+ const tacticalClimbModes = [
+ VerticalMode.OP_CLB,
+ ];
+
+ const tacticalDescentModes = [
+ VerticalMode.OP_DES,
+ VerticalMode.DES,
+ ];
+
+ // Check if we're in the climb or descent phase and compute an appropriate tactical path.
+ // A tactical path is a profile in the currently selected modes.
+ if (tacticalClimbModes.includes(fcuVerticalMode) || fcuVerticalMode === VerticalMode.VS && fcuVerticalSpeed > 0) {
+ const climbStrategy = fcuVerticalMode === VerticalMode.VS
+ ? new VerticalSpeedStrategy(this.computationParametersObserver, this.atmosphericConditions, fcuVerticalSpeed)
+ : new ClimbThrustClimbStrategy(this.computationParametersObserver, this.atmosphericConditions);
+
+ const climbWinds = new HeadwindProfile(this.windProfileFactory.getClimbWinds(), this.headingProfile);
+
+ this.climbPathBuilder.computeClimbPath(this.currentNdGeometryProfile, climbStrategy, speedProfile, climbWinds, fcuAltitude);
+ } else if (tacticalDescentModes.includes(fcuVerticalMode) || fcuVerticalMode === VerticalMode.VS && fcuVerticalSpeed < 0) {
+ // The idea here is that we compute a profile to FCU alt in the current modes, find the intercept with the managed profile. And then compute the managed profile from there.
+ const descentStrategy = this.getAppropriateTacticalDescentStrategy(fcuVerticalMode, fcuVerticalSpeed);
+
+ // Build path to FCU altitude.
+ this.tacticalDescentPathBuilder.buildTacticalDescentPath(this.currentNdGeometryProfile, descentStrategy, speedProfile, fcuAltitude);
+
+ if (this.isInManagedNav()) {
+ // Compute intercept between managed profile and tactical path.
+ this.addPredictedIntercept(fcuVerticalMode === VerticalMode.DES);
+ }
+ }
+
+ // After computing the tactical profile, we wish to finish it up in managed modes.
+ if (this.isInManagedNav() && this.currentNdGeometryProfile) {
+ this.currentNdGeometryProfile.checkpoints.push(
+ ...this.currentNavGeometryProfile.checkpoints
+ .slice()
+ .filter(({ distanceFromStart }) => distanceFromStart > this.currentNdGeometryProfile.lastCheckpoint?.distanceFromStart ?? 0),
+ );
+ }
+
+ this.currentNdGeometryProfile.finalizeProfile();
+
+ if (VnavConfig.DEBUG_PROFILE) {
+ console.log('this.currentNdGeometryProfile:', this.currentNdGeometryProfile);
+ }
+ }
+
+ private getAppropriateTacticalDescentStrategy(fcuVerticalMode: VerticalMode, fcuVerticalSpeed: FeetPerMinute) {
+ if (fcuVerticalMode === VerticalMode.VS && fcuVerticalSpeed < 0) {
+ return new VerticalSpeedStrategy(this.computationParametersObserver, this.atmosphericConditions, fcuVerticalSpeed);
+ }
+
+ if (this.aircraftToDescentProfileRelation.isValid && fcuVerticalMode === VerticalMode.DES) {
+ const vdev = this.aircraftToDescentProfileRelation.computeLinearDeviation();
+
+ if (vdev > 0 && this.aircraftToDescentProfileRelation.isPastTopOfDescent()) {
+ return new IdleDescentStrategy(
+ this.computationParametersObserver,
+ this.atmosphericConditions,
+ { flapConfig: FlapConf.CLEAN, gearExtended: false, speedbrakesExtended: fcuVerticalMode === VerticalMode.DES },
+ );
+ }
+
+ return new VerticalSpeedStrategy(
+ this.computationParametersObserver,
+ this.atmosphericConditions,
+ this.aircraftToDescentProfileRelation.isAboveSpeedLimitAltitude() ? -1000 : -500,
+ );
+
+ // TODO: Implement prediction in the case of being below profile, but within the geometric path
+ // In that case, we should use a constant path angle, but I don't have a DescentStrategy for this yet.
+ }
+
+ return new IdleDescentStrategy(
+ this.computationParametersObserver,
+ this.atmosphericConditions,
+ );
+ }
+
+ private shouldObeySpeedConstraints(): boolean {
+ const { fcuSpeed } = this.computationParametersObserver.get();
+
+ // TODO: Take MACH into account
+ return this.isInManagedNav() && fcuSpeed <= 0;
+ }
+
+ shouldObeyAltitudeConstraints(): boolean {
+ const { fcuArmedLateralMode, fcuArmedVerticalMode, fcuVerticalMode } = this.computationParametersObserver.get();
+
+ const verticalModesToApplyAltitudeConstraintsFor = [
+ VerticalMode.CLB,
+ VerticalMode.ALT,
+ VerticalMode.ALT_CPT,
+ VerticalMode.ALT_CST_CPT,
+ VerticalMode.ALT_CST,
+ VerticalMode.DES,
+ ];
+
+ return isArmed(fcuArmedVerticalMode, ArmedVerticalMode.CLB)
+ || isArmed(fcuArmedLateralMode, ArmedLateralMode.NAV)
+ || verticalModesToApplyAltitudeConstraintsFor.includes(fcuVerticalMode);
+ }
+
+ computeVerticalProfileForExpediteClimb(): SelectedGeometryProfile | undefined {
+ try {
+ const { fcuAltitude, presentPosition, fuelOnBoard } = this.computationParametersObserver.get();
+
+ const greenDotSpeed = Simplane.getGreenDotSpeed();
+ if (!greenDotSpeed) {
+ return undefined;
+ }
+
+ const selectedSpeedProfile = new ExpediteSpeedProfile(greenDotSpeed);
+ const expediteGeometryProfile = new SelectedGeometryProfile();
+ const climbStrategy = new ClimbThrustClimbStrategy(this.computationParametersObserver, this.atmosphericConditions);
+ const climbWinds = new HeadwindProfile(this.windProfileFactory.getClimbWinds(), this.headingProfile);
+
+ expediteGeometryProfile.addPresentPositionCheckpoint(presentPosition, fuelOnBoard);
+ this.climbPathBuilder.computeClimbPath(expediteGeometryProfile, climbStrategy, selectedSpeedProfile, climbWinds, fcuAltitude);
+
+ expediteGeometryProfile.finalizeProfile();
+
+ if (VnavConfig.DEBUG_PROFILE) {
+ console.log(expediteGeometryProfile);
}
- this.currentApproachProfile = DecelPathBuilder.computeDecelPath(geometry);
- this.currentDescentProfile = DescentBuilder.computeDescentPath(geometry, this.currentApproachProfile);
- this.guidanceController.pseudoWaypoints.acceptVerticalProfile();
- } else if (DEBUG) {
- console.warn('[FMS/VNAV] Did not compute vertical profile. Reason: no legs in flight plan.');
+ return expediteGeometryProfile;
+ } catch (e) {
+ console.error(e);
+ return new SelectedGeometryProfile();
+ }
+ }
+
+ getCurrentSpeedConstraint(): Knots {
+ if (this.shouldObeySpeedConstraints()) {
+ return this.currentMcduSpeedProfile.getCurrentSpeedTarget();
}
+
+ return Infinity;
+ }
+
+ isInManagedNav(): boolean {
+ const { fcuLateralMode, fcuArmedLateralMode } = this.computationParametersObserver.get();
+
+ return fcuLateralMode === LateralMode.NAV || isArmed(fcuArmedLateralMode, ArmedLateralMode.NAV);
}
private updateGuidance(): void {
@@ -134,4 +494,34 @@ export class VnavDriver implements GuidanceComponent {
SimVar.SetSimVarValue('L:A32NX_FG_TARGET_ALTITUDE', 'number', this.targetAltitude);
}
}
+
+ private addPredictedIntercept(isInManagedDes: boolean) {
+ const intercept = ProfileInterceptCalculator.calculateIntercept(
+ this.currentNdGeometryProfile.checkpoints,
+ this.currentNavGeometryProfile.checkpoints.filter(({ reason }) => reason !== VerticalCheckpointReason.PresentPosition),
+ );
+
+ if (!intercept) {
+ return;
+ }
+
+ // Only draw intercept if it's far enough away
+ const reason = isInManagedDes ? VerticalCheckpointReason.InterceptDescentProfileManaged : VerticalCheckpointReason.InterceptDescentProfileSelected;
+ this.currentNdGeometryProfile.addInterpolatedCheckpoint(intercept, { reason });
+
+ const pposDistanceFromStart = this.currentNavGeometryProfile.findVerticalCheckpoint(VerticalCheckpointReason.PresentPosition)?.distanceFromStart ?? 0;
+ const isTooCloseToDrawIntercept = intercept - pposDistanceFromStart < 3;
+
+ // We remove all subsequent checkpoints after the intercept, since those will just be on the managed profile
+ const interceptIndex = this.currentNdGeometryProfile.checkpoints.findIndex((checkpoint) => checkpoint.reason === reason);
+ this.currentNdGeometryProfile.checkpoints.splice(isTooCloseToDrawIntercept ? interceptIndex : interceptIndex + 1);
+ }
+
+ getLinearDeviation(): Feet | null {
+ if (!this.aircraftToDescentProfileRelation.isValid) {
+ return null;
+ }
+
+ return this.aircraftToDescentProfileRelation.computeLinearDeviation();
+ }
}
diff --git a/src/fmgc/src/guidance/vnav/climb/ClimbPathBuilder.ts b/src/fmgc/src/guidance/vnav/climb/ClimbPathBuilder.ts
index f98081e84194..7809da91b6aa 100644
--- a/src/fmgc/src/guidance/vnav/climb/ClimbPathBuilder.ts
+++ b/src/fmgc/src/guidance/vnav/climb/ClimbPathBuilder.ts
@@ -1,85 +1,377 @@
-import { Geometry } from '@fmgc/guidance/Geometry';
-import { Predictions } from '../Predictions';
-import { ClimbProfileBuilderResult } from './ClimbProfileBuilderResult';
-import { Common } from '../common';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { ManagedSpeedType, SpeedProfile } from '@fmgc/guidance/vnav/climb/SpeedProfile';
+import { ArmedVerticalMode, isArmed, VerticalMode } from '@shared/autopilot';
+import { ClimbStrategy } from '@fmgc/guidance/vnav/climb/ClimbStrategy';
+import { EngineModel } from '@fmgc/guidance/vnav/EngineModel';
+import { WindComponent } from '@fmgc/guidance/vnav/wind';
+import { VnavConfig } from '@fmgc/guidance/vnav/VnavConfig';
+import { HeadwindProfile } from '@fmgc/guidance/vnav/wind/HeadwindProfile';
+import { Predictions, StepResults } from '../Predictions';
+import { VerticalCheckpoint, VerticalCheckpointReason } from '../profile/NavGeometryProfile';
+import { BaseGeometryProfile } from '../profile/BaseGeometryProfile';
+import { AtmosphericConditions } from '../AtmosphericConditions';
export class ClimbPathBuilder {
- static computeClimbPath(
- _geometry: Geometry,
- ): ClimbProfileBuilderResult {
- const airfieldElevation = SimVar.GetSimVarValue('L:A32NX_DEPARTURE_ELEVATION', 'feet') ?? 0;
- const accelerationAltitude = airfieldElevation + 1500;
-
- const midwayAltitudeSrs = (accelerationAltitude + airfieldElevation) / 2;
- const isaDev = 8;
- const v2 = SimVar.GetSimVarValue('L:AIRLINER_V2_SPEED', 'knots') ?? 130;
- console.log(`v2 + 10: ${JSON.stringify(v2 + 10)}`);
-
- const commandedN1Toga = SimVar.GetSimVarValue('L:A32NX_AUTOTHRUST_THRUST_LIMIT', 'Percent') ?? 0;
- console.log(`commandedN1: ${JSON.stringify(commandedN1Toga)}`);
-
- const thetaSrs = Common.getTheta(midwayAltitudeSrs, isaDev);
- const deltaSrs = Common.getDelta(thetaSrs);
- const machSrs = Common.CAStoMach(v2 + 10, deltaSrs);
-
- console.log(`mach: ${JSON.stringify(machSrs)}`);
-
- const zeroFuelWeight = 101853.57;
- const fuelWeight = SimVar.GetSimVarValue('FUEL TOTAL QUANTITY WEIGHT', 'lbs');
- console.log(`fuelWeight: ${JSON.stringify(fuelWeight)}`);
-
- const takeoffRollDistance = this.computeTakeOffRollDistance();
- console.log(`takeoffRollDistance: ${JSON.stringify(takeoffRollDistance)}`);
-
- const { pathAngle: pathAngleSrs, distanceTraveled: distanceTraveledSrs } = Predictions.altitudeStep(
- airfieldElevation,
- accelerationAltitude - airfieldElevation,
- v2 + 10,
- machSrs,
- commandedN1Toga,
+ constructor(private computationParametersObserver: VerticalProfileComputationParametersObserver, private atmosphericConditions: AtmosphericConditions) { }
+
+ /**
+ * Compute climb profile assuming climb thrust until top of climb. This does not care if we're below acceleration/thrust reduction altitude.
+ * @param profile
+ * @returns
+ */
+ computeClimbPath(
+ profile: BaseGeometryProfile, climbStrategy: ClimbStrategy, speedProfile: SpeedProfile, windProfile: HeadwindProfile, targetAltitude: Feet,
+ ) {
+ const { fcuVerticalMode, fcuArmedVerticalMode } = this.computationParametersObserver.get();
+
+ this.addClimbSteps(profile, climbStrategy, speedProfile, windProfile, targetAltitude, VerticalCheckpointReason.TopOfClimb);
+
+ if (this.shouldAddFcuAltAsCheckpoint(fcuVerticalMode, fcuArmedVerticalMode)) {
+ this.addFcuAltitudeAsCheckpoint(profile);
+ }
+
+ if (speedProfile.shouldTakeSpeedLimitIntoAccount()) {
+ this.addSpeedLimitAsCheckpoint(profile);
+ }
+ }
+
+ private addClimbSteps(
+ profile: BaseGeometryProfile,
+ climbStrategy: ClimbStrategy,
+ speedProfile: SpeedProfile,
+ windProfile: HeadwindProfile,
+ finalAltitude: Feet,
+ finalAltitudeReason: VerticalCheckpointReason = VerticalCheckpointReason.AtmosphericConditions,
+ ) {
+ for (const constraint of profile.maxAltitudeConstraints) {
+ const { maxAltitude: constraintAltitude, distanceFromStart: constraintDistanceFromStart } = constraint;
+
+ if (constraintAltitude >= finalAltitude) {
+ break;
+ }
+
+ // Code is WIP. Idea is to make ClimbPathBuilder more aware of speed constraints,
+ // so we can properly integrate acceleration segments
+
+ if (constraintAltitude > profile.lastCheckpoint.altitude) {
+ // Continue climb
+ if (profile.lastCheckpoint.reason === VerticalCheckpointReason.AltitudeConstraint) {
+ profile.lastCheckpoint.reason = VerticalCheckpointReason.ContinueClimb;
+ }
+
+ // Mark where we are
+ let indexToResetTo = profile.checkpoints.length;
+ // Try going to the next altitude
+ this.buildIteratedClimbSegment(profile, climbStrategy, speedProfile, windProfile, profile.lastCheckpoint.altitude, constraintAltitude);
+
+ let currentSpeedConstraint = speedProfile.getMaxClimbSpeedConstraint(profile.lastCheckpoint.distanceFromStart);
+ for (let i = 0; i++ < 10 && currentSpeedConstraint; currentSpeedConstraint = speedProfile.getMaxClimbSpeedConstraint(profile.lastCheckpoint.distanceFromStart)) {
+ // This means we did not pass a constraint during the climb
+ if (currentSpeedConstraint.distanceFromStart > profile.lastCheckpoint.distanceFromStart) {
+ break;
+ }
+
+ // Reset
+ profile.checkpoints.splice(indexToResetTo);
+
+ const averageDistanceFromStart = (currentSpeedConstraint.distanceFromStart + profile.lastCheckpoint.distanceFromStart) / 2;
+ const headwind = windProfile.getHeadwindComponent(averageDistanceFromStart, profile.lastCheckpoint.altitude);
+
+ // Use distance step instead
+ this.distanceStepFromLastCheckpoint(
+ profile,
+ climbStrategy,
+ currentSpeedConstraint.distanceFromStart - profile.lastCheckpoint.distanceFromStart,
+ headwind,
+ VerticalCheckpointReason.SpeedConstraint,
+ );
+
+ // Repeat
+ indexToResetTo = profile.checkpoints.length;
+ this.buildIteratedClimbSegment(profile, climbStrategy, speedProfile, windProfile, profile.lastCheckpoint.altitude, constraintAltitude);
+ }
+
+ // We reach the target altitude before the constraint, so we insert a level segment.
+ if (profile.lastCheckpoint.distanceFromStart < constraintDistanceFromStart) {
+ profile.lastCheckpoint.reason = VerticalCheckpointReason.LevelOffForClimbConstraint;
+
+ this.addLevelSegmentSteps(profile, speedProfile, constraintDistanceFromStart);
+ }
+ } else if (Math.abs(profile.lastCheckpoint.altitude - constraintAltitude) < 250) {
+ // Continue in level flight to the next constraint
+ this.addLevelSegmentSteps(profile, speedProfile, constraintDistanceFromStart);
+ }
+ }
+
+ if (profile.lastCheckpoint.reason === VerticalCheckpointReason.AltitudeConstraint) {
+ profile.lastCheckpoint.reason = VerticalCheckpointReason.ContinueClimb;
+ }
+
+ const { managedClimbSpeedMach } = this.computationParametersObserver.get();
+
+ // We get here if there are still waypoints with speed constrainst after all the altitude constraints
+ for (const speedConstraint of profile.maxClimbSpeedConstraints) {
+ const { distanceFromStart, altitude, speed, remainingFuelOnBoard } = profile.lastCheckpoint;
+
+ if (distanceFromStart > speedConstraint.distanceFromStart) {
+ continue;
+ }
+
+ const speedTarget = speedProfile.getTarget(distanceFromStart, altitude, ManagedSpeedType.Climb);
+ if ((speedTarget - speed) > 1) {
+ const headwind = windProfile.getHeadwindComponent(distanceFromStart, altitude);
+
+ const accelerationStep = climbStrategy.predictToSpeed(altitude, speedTarget, speed, managedClimbSpeedMach, remainingFuelOnBoard, headwind);
+
+ // If we shoot through the final altitude trying to accelerate, pretend we didn't accelerate all the way
+ if (accelerationStep.finalAltitude > finalAltitude) {
+ const scaling = (accelerationStep.finalAltitude - accelerationStep.initialAltitude) !== 0
+ ? (finalAltitude - accelerationStep.initialAltitude) / (accelerationStep.finalAltitude - accelerationStep.initialAltitude)
+ : 0;
+
+ this.scaleStepBasedOnLastCheckpoint(profile.lastCheckpoint, accelerationStep, scaling);
+ }
+
+ this.addCheckpointFromStep(profile, accelerationStep, VerticalCheckpointReason.AtmosphericConditions);
+ }
+
+ if (speedConstraint.distanceFromStart > profile.lastCheckpoint.distanceFromStart) {
+ const averageDistanceFromStart = (speedConstraint.distanceFromStart + profile.lastCheckpoint.distanceFromStart) / 2;
+ const headwind = windProfile.getHeadwindComponent(averageDistanceFromStart, profile.lastCheckpoint.altitude);
+
+ this.distanceStepFromLastCheckpoint(
+ profile, climbStrategy, speedConstraint.distanceFromStart - profile.lastCheckpoint.distanceFromStart, headwind, VerticalCheckpointReason.AtmosphericConditions,
+ );
+
+ // This occurs if we somehow overshot the target altitude
+ if (profile.lastCheckpoint.altitude > finalAltitude) {
+ profile.checkpoints.splice(profile.checkpoints.length - 1);
+
+ this.buildIteratedClimbSegment(profile, climbStrategy, speedProfile, windProfile, profile.lastCheckpoint.altitude, finalAltitude);
+ }
+ }
+ }
+
+ // We get here if we have passed all speed and altitude constraints, but are not at our final altitude yet.
+ this.buildIteratedClimbSegment(profile, climbStrategy, speedProfile, windProfile, profile.lastCheckpoint.altitude, finalAltitude);
+ profile.lastCheckpoint.reason = finalAltitudeReason;
+ }
+
+ private buildIteratedClimbSegment(
+ profile: BaseGeometryProfile,
+ climbStrategy: ClimbStrategy,
+ speedProfile: SpeedProfile,
+ windProfile: HeadwindProfile,
+ startingAltitude: Feet,
+ targetAltitude: Feet,
+ ): void {
+ const { managedClimbSpeedMach } = this.computationParametersObserver.get();
+
+ for (let altitude = startingAltitude; altitude < targetAltitude;) {
+ const { speed, remainingFuelOnBoard, distanceFromStart } = profile.lastCheckpoint;
+
+ const speedTarget = speedProfile.getTarget(distanceFromStart, altitude, ManagedSpeedType.Climb);
+
+ const headwind = windProfile.getHeadwindComponent(distanceFromStart, altitude);
+
+ const step = speedTarget - speed < 1
+ ? climbStrategy.predictToAltitude(altitude, Math.min(altitude + 1500, targetAltitude), speedTarget, managedClimbSpeedMach, remainingFuelOnBoard, headwind)
+ : climbStrategy.predictToSpeed(altitude, speedTarget, speed, managedClimbSpeedMach, remainingFuelOnBoard, headwind);
+
+ if (step.finalAltitude - targetAltitude > 10) {
+ const scaling = (step.finalAltitude - step.initialAltitude) !== 0
+ ? (targetAltitude - step.initialAltitude) / (step.finalAltitude - step.initialAltitude)
+ : 0;
+ this.scaleStepBasedOnLastCheckpoint(profile.lastCheckpoint, step, scaling);
+ }
+
+ this.addCheckpointFromStep(profile, step, VerticalCheckpointReason.AtmosphericConditions);
+
+ altitude = step.finalAltitude;
+ }
+ }
+
+ private distanceStepFromLastCheckpoint(profile: BaseGeometryProfile, climbStrategy: ClimbStrategy, distance: NauticalMiles, headwind: WindComponent, reason: VerticalCheckpointReason) {
+ const { managedClimbSpeedMach } = this.computationParametersObserver.get();
+ const { altitude, speed: initialSpeed, remainingFuelOnBoard } = profile.lastCheckpoint;
+
+ const step = climbStrategy.predictToDistance(altitude, distance, initialSpeed, managedClimbSpeedMach, remainingFuelOnBoard, headwind);
+
+ this.addCheckpointFromStep(profile, step, reason);
+ }
+
+ private addLevelSegmentSteps(profile: BaseGeometryProfile, speedProfile: SpeedProfile, toDistanceFromStart: NauticalMiles): void {
+ // The only reason we have to build this iteratively is because there could be speed constraints along the way
+ const altitude = profile.lastCheckpoint.altitude;
+
+ // Go over all constraints
+ for (const speedConstraint of profile.maxClimbSpeedConstraints) {
+ // Ignore constraint since we're already past it
+ if (profile.lastCheckpoint.distanceFromStart >= speedConstraint.distanceFromStart || toDistanceFromStart <= speedConstraint.distanceFromStart) {
+ continue;
+ }
+
+ const currentSpeed = profile.lastCheckpoint.speed;
+ const speedTarget = speedProfile.getTarget(profile.lastCheckpoint.distanceFromStart, altitude, ManagedSpeedType.Climb);
+
+ if (speedTarget > currentSpeed) {
+ const step = this.computeLevelFlightAccelerationStep(altitude, currentSpeed, speedTarget, profile.lastCheckpoint.remainingFuelOnBoard);
+
+ // We could not accelerate in time
+ if (profile.lastCheckpoint.distanceFromStart + step.distanceTraveled > speedConstraint.distanceFromStart) {
+ const scaling = step.distanceTraveled / (speedConstraint.distanceFromStart - profile.lastCheckpoint.distanceFromStart);
+
+ this.scaleStepBasedOnLastCheckpoint(profile.lastCheckpoint, step, scaling);
+ this.addCheckpointFromStep(profile, step, VerticalCheckpointReason.AtmosphericConditions);
+
+ continue;
+ } else {
+ // End of acceleration
+ this.addCheckpointFromStep(profile, step, VerticalCheckpointReason.AtmosphericConditions);
+ }
+ }
+
+ // Compute step after accelerating to next constraint
+ const levelStepToConstraint = this.computeLevelFlightSegmentPrediction(
+ speedConstraint.distanceFromStart - profile.lastCheckpoint.distanceFromStart,
+ altitude,
+ profile.lastCheckpoint.speed,
+ profile.lastCheckpoint.remainingFuelOnBoard,
+ );
+
+ this.addCheckpointFromStep(profile, levelStepToConstraint, VerticalCheckpointReason.AltitudeConstraint);
+ }
+
+ // TODO: This exact piece of code appears a couple of lines above, extract to function!
+ const currentSpeed = profile.lastCheckpoint.speed;
+ const speedTarget = speedProfile.getTarget(profile.lastCheckpoint.distanceFromStart, altitude, ManagedSpeedType.Climb);
+
+ if (speedTarget > currentSpeed) {
+ const accelerationStep = this.computeLevelFlightAccelerationStep(altitude, currentSpeed, speedTarget, profile.lastCheckpoint.remainingFuelOnBoard);
+
+ // We could not accelerate in time
+ if (profile.lastCheckpoint.distanceFromStart + accelerationStep.distanceTraveled > toDistanceFromStart) {
+ const scaling = accelerationStep.distanceTraveled / (toDistanceFromStart - profile.lastCheckpoint.distanceFromStart);
+ this.scaleStepBasedOnLastCheckpoint(profile.lastCheckpoint, accelerationStep, scaling);
+ this.addCheckpointFromStep(profile, accelerationStep, VerticalCheckpointReason.AtmosphericConditions);
+
+ return;
+ }
+
+ // End of acceleration
+ this.addCheckpointFromStep(profile, accelerationStep, VerticalCheckpointReason.AtmosphericConditions);
+ }
+
+ const levelStepToConstraint = this.computeLevelFlightSegmentPrediction(
+ toDistanceFromStart - profile.lastCheckpoint.distanceFromStart,
+ altitude,
+ profile.lastCheckpoint.speed,
+ profile.lastCheckpoint.remainingFuelOnBoard,
+ );
+
+ this.addCheckpointFromStep(profile, levelStepToConstraint, VerticalCheckpointReason.AltitudeConstraint);
+ }
+
+ private computeLevelFlightSegmentPrediction(stepSize: Feet, altitude: Feet, initialSpeed: Knots, fuelWeight: number): StepResults {
+ const { zeroFuelWeight, managedClimbSpeedMach } = this.computationParametersObserver.get();
+
+ return Predictions.levelFlightStep(
+ altitude,
+ stepSize,
+ initialSpeed,
+ managedClimbSpeedMach,
zeroFuelWeight,
fuelWeight,
0,
- isaDev,
- 36000,
- false,
+ this.atmosphericConditions.isaDeviation,
);
- console.log(`pathAngleSrs: ${pathAngleSrs}`);
- console.log(`distanceToAccelerationAltitude: ${JSON.stringify(distanceTraveledSrs)}`);
-
- const cruiseAltitude = 20000;
- const climbSpeed = v2 + 10;
-
- const commandedN1Climb = SimVar.GetSimVarValue('L:A32NX_AUTOTHRUST_THRUST_LIMIT', 'Percent') ?? 0;
- const midwayAltitudeClimb = (cruiseAltitude + accelerationAltitude) / 2;
+ }
- const thetaClimb = Common.getTheta(midwayAltitudeClimb, isaDev);
- const deltaClimb = Common.getDelta(thetaClimb);
- const machClimb = Common.CAStoMach(climbSpeed, deltaClimb);
+ private computeLevelFlightAccelerationStep(altitude: Feet, initialSpeed: Knots, speedTarget: Knots, fuelWeight: number): StepResults {
+ const { zeroFuelWeight, managedClimbSpeedMach, tropoPause } = this.computationParametersObserver.get();
- const { pathAngle: pathAngleClimb, distanceTraveled: distanceTraveledClb } = Predictions.altitudeStep(
- accelerationAltitude,
- cruiseAltitude - accelerationAltitude,
- climbSpeed,
- machClimb,
- commandedN1Climb,
+ return Predictions.speedChangeStep(
+ 0,
+ altitude,
+ initialSpeed,
+ speedTarget,
+ managedClimbSpeedMach,
+ managedClimbSpeedMach,
+ getClimbThrustN1Limit(this.atmosphericConditions, altitude, (initialSpeed + speedTarget) / 2, managedClimbSpeedMach), // TOD0
zeroFuelWeight,
fuelWeight,
0,
- isaDev,
- 36000,
- false,
+ this.atmosphericConditions.isaDeviation,
+ tropoPause,
);
- console.log(`pathAngleClimb: ${pathAngleClimb}`);
- console.log(`distanceToCruiseAltitude: ${JSON.stringify(distanceTraveledClb)}`);
+ }
+
+ addSpeedLimitAsCheckpoint(profile: BaseGeometryProfile) {
+ const { climbSpeedLimit: { underAltitude }, presentPosition: { alt }, cruiseAltitude } = this.computationParametersObserver.get();
- console.log(`[FMS/VNAV] T/C: ${JSON.stringify(takeoffRollDistance + distanceTraveledSrs + distanceTraveledClb)}`);
+ if (underAltitude <= alt || underAltitude > cruiseAltitude) {
+ return;
+ }
- return { distanceToAccelerationAltitude: distanceTraveledSrs };
+ const distance = profile.interpolateDistanceAtAltitude(underAltitude);
+
+ profile.addInterpolatedCheckpoint(distance, { reason: VerticalCheckpointReason.CrossingClimbSpeedLimit });
}
- static computeTakeOffRollDistance(): number {
- // TODO
- return 1;
+ private addFcuAltitudeAsCheckpoint(profile: BaseGeometryProfile) {
+ const { fcuAltitude, presentPosition, cruiseAltitude } = this.computationParametersObserver.get();
+
+ if (fcuAltitude <= presentPosition.alt || fcuAltitude > cruiseAltitude) {
+ if (VnavConfig.DEBUG_PROFILE) {
+ console.warn(`[FMS/VNAV] FCU altitude was above cruise altitude (${fcuAltitude} > ${cruiseAltitude})`);
+ }
+
+ return;
+ }
+
+ const distance = profile.interpolateDistanceAtAltitude(fcuAltitude);
+
+ profile.addInterpolatedCheckpoint(distance, { reason: VerticalCheckpointReason.CrossingFcuAltitudeClimb });
}
+
+ private shouldAddFcuAltAsCheckpoint(verticalMode: VerticalMode, armedVerticalMode: ArmedVerticalMode) {
+ const verticalModesToShowLevelOffArrowFor = [
+ VerticalMode.OP_CLB,
+ VerticalMode.VS,
+ VerticalMode.FPA,
+ VerticalMode.CLB,
+ VerticalMode.SRS,
+ VerticalMode.SRS_GA,
+ ];
+
+ return isArmed(armedVerticalMode, ArmedVerticalMode.CLB) || verticalModesToShowLevelOffArrowFor.includes(verticalMode);
+ }
+
+ private addCheckpointFromStep(profile: BaseGeometryProfile, step: StepResults, reason: VerticalCheckpointReason) {
+ profile.addCheckpointFromLast(({ distanceFromStart, secondsFromPresent, remainingFuelOnBoard }) => ({
+ reason,
+ distanceFromStart: distanceFromStart + step.distanceTraveled,
+ altitude: step.finalAltitude,
+ secondsFromPresent: secondsFromPresent + step.timeElapsed,
+ speed: step.speed,
+ remainingFuelOnBoard: remainingFuelOnBoard - step.fuelBurned,
+ mach: this.computationParametersObserver.get().managedClimbSpeedMach,
+ }));
+ }
+
+ private scaleStepBasedOnLastCheckpoint(lastCheckpoint: VerticalCheckpoint, step: StepResults, scaling: number) {
+ step.distanceTraveled *= scaling;
+ step.fuelBurned *= scaling;
+ step.timeElapsed *= scaling;
+ step.finalAltitude = (1 - scaling) * lastCheckpoint.altitude + scaling * step.initialAltitude;
+ step.speed = (1 - scaling) * lastCheckpoint.speed + scaling * step.speed;
+ }
+}
+
+// TODO: Deduplicate this from here and ClimbStrategy.ts
+function getClimbThrustN1Limit(atmosphericConditions: AtmosphericConditions, altitude: Feet, speed: Knots, maxMach: Mach) {
+ const climbSpeedMach = Math.min(maxMach, atmosphericConditions.computeMachFromCas(altitude, speed));
+ const estimatedTat = atmosphericConditions.totalAirTemperatureFromMach(altitude, climbSpeedMach);
+
+ return EngineModel.tableInterpolation(EngineModel.maxClimbThrustTableLeap, estimatedTat, altitude);
}
diff --git a/src/fmgc/src/guidance/vnav/climb/ClimbProfileBuilderResult.ts b/src/fmgc/src/guidance/vnav/climb/ClimbProfileBuilderResult.ts
index 60d12722720c..e69de29bb2d1 100644
--- a/src/fmgc/src/guidance/vnav/climb/ClimbProfileBuilderResult.ts
+++ b/src/fmgc/src/guidance/vnav/climb/ClimbProfileBuilderResult.ts
@@ -1,3 +0,0 @@
-export interface ClimbProfileBuilderResult {
- distanceToAccelerationAltitude: number
-}
diff --git a/src/fmgc/src/guidance/vnav/climb/ClimbStrategy.ts b/src/fmgc/src/guidance/vnav/climb/ClimbStrategy.ts
new file mode 100644
index 000000000000..2d0564ab69bd
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/climb/ClimbStrategy.ts
@@ -0,0 +1,162 @@
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { DescentStrategy } from '@fmgc/guidance/vnav/descent/DescentStrategy';
+import { WindComponent } from '@fmgc/guidance/vnav/wind';
+import { EngineModel } from '../EngineModel';
+import { FlapConf } from '../common';
+import { Predictions, StepResults } from '../Predictions';
+import { AtmosphericConditions } from '../AtmosphericConditions';
+
+export interface ClimbStrategy {
+ /**
+ * Computes predictions for a single segment using the atmospheric conditions in the middle.
+ * @param initialAltitude Altitude at the start of climb
+ * @param finalAltitude Altitude to terminate the climb
+ * @param speed
+ * @param mach
+ * @param fuelOnBoard Remainging fuel on board at the start of the climb
+ * @returns `StepResults`
+ */
+ predictToAltitude(initialAltitude: number, finalAltitude: number, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent): StepResults;
+
+ predictToDistance(initialAltitude: number, distance: NauticalMiles, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent): StepResults;
+
+ predictToSpeed(initialAltitude: number, finalSpeed: Knots, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent): StepResults;
+}
+
+export class VerticalSpeedStrategy implements ClimbStrategy, DescentStrategy {
+ constructor(private observer: VerticalProfileComputationParametersObserver, private atmosphericConditions: AtmosphericConditions, private verticalSpeed: FeetPerMinute) { }
+
+ predictToAltitude(initialAltitude: Feet, finalAltitude: Feet, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent): StepResults {
+ const { zeroFuelWeight, perfFactor } = this.observer.get();
+
+ return Predictions.verticalSpeedStep(
+ initialAltitude,
+ finalAltitude,
+ this.verticalSpeed,
+ speed,
+ mach,
+ zeroFuelWeight,
+ fuelOnBoard,
+ this.atmosphericConditions.isaDeviation,
+ headwindComponent.value,
+ perfFactor,
+ );
+ }
+
+ predictToDistance(initialAltitude: Feet, distance: NauticalMiles, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent): StepResults {
+ const { zeroFuelWeight, perfFactor } = this.observer.get();
+
+ return Predictions.verticalSpeedDistanceStep(
+ initialAltitude,
+ distance,
+ this.verticalSpeed,
+ speed,
+ mach,
+ zeroFuelWeight,
+ fuelOnBoard,
+ this.atmosphericConditions.isaDeviation,
+ headwindComponent.value,
+ perfFactor,
+ );
+ }
+
+ predictToSpeed(initialAltitude: Feet, finalSpeed: Knots, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent): StepResults {
+ const { zeroFuelWeight, perfFactor, tropoPause, managedClimbSpeedMach } = this.observer.get();
+
+ return Predictions.verticalSpeedStepWithSpeedChange(
+ initialAltitude,
+ speed,
+ finalSpeed,
+ this.verticalSpeed,
+ mach,
+ getClimbThrustN1Limit(this.atmosphericConditions, initialAltitude, speed, managedClimbSpeedMach),
+ zeroFuelWeight,
+ fuelOnBoard,
+ headwindComponent.value,
+ this.atmosphericConditions.isaDeviation,
+ tropoPause,
+ false,
+ FlapConf.CLEAN,
+ perfFactor,
+ );
+ }
+
+ predictToDistanceBackwards(_finalAltitude: number, _distance: NauticalMiles, _speed: Knots, _mach: Mach, _fuelOnBoard: number): StepResults {
+ throw new Error('[FMS/VNAV] Backwards distance predictions not implemented for V/S strategy');
+ }
+
+ predictToSpeedBackwards(_finalAltitude: number, _finalSpeed: Knots, _speed: Knots, _mach: Mach, _fuelOnBoard: number): StepResults {
+ throw new Error('[FMS/VNAV] Backwards speed predictions not implemented for V/S strategy');
+ }
+}
+
+export class ClimbThrustClimbStrategy implements ClimbStrategy {
+ constructor(private observer: VerticalProfileComputationParametersObserver, private atmosphericConditions: AtmosphericConditions) { }
+
+ predictToAltitude(initialAltitude: Feet, finalAltitude: Feet, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent): StepResults {
+ const { zeroFuelWeight, tropoPause, perfFactor, managedClimbSpeedMach } = this.observer.get();
+
+ return Predictions.altitudeStep(
+ initialAltitude,
+ finalAltitude - initialAltitude,
+ speed,
+ mach,
+ getClimbThrustN1Limit(this.atmosphericConditions, (initialAltitude + finalAltitude) / 2, speed, managedClimbSpeedMach),
+ zeroFuelWeight,
+ fuelOnBoard,
+ headwindComponent.value,
+ this.atmosphericConditions.isaDeviation,
+ tropoPause,
+ false,
+ FlapConf.CLEAN,
+ perfFactor,
+ );
+ }
+
+ predictToDistance(initialAltitude: Feet, distance: NauticalMiles, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent): StepResults {
+ const { zeroFuelWeight, tropoPause, perfFactor, managedClimbSpeedMach } = this.observer.get();
+
+ return Predictions.distanceStep(
+ initialAltitude,
+ distance,
+ speed,
+ mach,
+ getClimbThrustN1Limit(this.atmosphericConditions, initialAltitude, speed, managedClimbSpeedMach),
+ zeroFuelWeight,
+ fuelOnBoard,
+ headwindComponent.value,
+ this.atmosphericConditions.isaDeviation,
+ tropoPause,
+ false,
+ FlapConf.CLEAN,
+ perfFactor,
+ );
+ }
+
+ predictToSpeed(initialAltitude: Feet, finalSpeed: Knots, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent): StepResults {
+ const { zeroFuelWeight, perfFactor, tropoPause, managedClimbSpeedMach } = this.observer.get();
+
+ return Predictions.altitudeStepWithSpeedChange(
+ initialAltitude,
+ speed,
+ finalSpeed,
+ mach,
+ getClimbThrustN1Limit(this.atmosphericConditions, initialAltitude, speed, managedClimbSpeedMach),
+ zeroFuelWeight,
+ fuelOnBoard,
+ headwindComponent.value,
+ this.atmosphericConditions.isaDeviation,
+ tropoPause,
+ false,
+ FlapConf.CLEAN,
+ perfFactor,
+ );
+ }
+}
+
+function getClimbThrustN1Limit(atmosphericConditions: AtmosphericConditions, altitude: Feet, speed: Knots, maxMach: Mach) {
+ const climbSpeedMach = Math.min(maxMach, atmosphericConditions.computeMachFromCas(altitude, speed));
+ const estimatedTat = atmosphericConditions.totalAirTemperatureFromMach(altitude, climbSpeedMach);
+
+ return EngineModel.tableInterpolation(EngineModel.maxClimbThrustTableLeap, estimatedTat, altitude);
+}
diff --git a/src/fmgc/src/guidance/vnav/climb/SpeedProfile.ts b/src/fmgc/src/guidance/vnav/climb/SpeedProfile.ts
new file mode 100644
index 000000000000..55ae4ce4b819
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/climb/SpeedProfile.ts
@@ -0,0 +1,416 @@
+import { MaxSpeedConstraint } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { FmgcFlightPhase } from '@shared/flightphase';
+
+export enum ManagedSpeedType {
+ Climb,
+ Cruise,
+ Descent
+}
+
+export interface SpeedProfile {
+ /**
+ * This is used for predictions
+ * @param distanceFromStart - The distance at which the target should be queried
+ * @param altitude
+ */
+ getTarget(distanceFromStart: NauticalMiles, altitude: Feet, managedSpeedType: ManagedSpeedType): Knots;
+
+ /**
+ * This is used for predictions
+ * @param altitude
+ * @param managedSpeedType
+ */
+ getTargetWithoutConstraints(altitude: NauticalMiles, managedSpeedType: ManagedSpeedType): Knots;
+
+ /**
+ * This is used for guidance.
+ */
+ getCurrentSpeedTarget(): Knots;
+ shouldTakeSpeedLimitIntoAccount(): boolean;
+
+ /**
+ * This is used for predictions
+ * @param distanceFromStart
+ */
+ getMaxClimbSpeedConstraint(distanceFromStart: NauticalMiles): MaxSpeedConstraint;
+
+ /**
+ * This is used for predictions
+ * @param distanceAlongTrack
+ */
+ getMaxDescentSpeedConstraint(distanceAlongTrack: NauticalMiles): MaxSpeedConstraint;
+}
+
+function constraintToSpeed(constraint?: MaxSpeedConstraint): Knots {
+ return constraint?.maxSpeed ?? Infinity;
+}
+
+/**
+ * This class's purpose is to provide a predicted speed at a given position and altitude.
+ */
+export class McduSpeedProfile implements SpeedProfile {
+ private maxSpeedCacheHits: number = 0;
+
+ private maxSpeedLookups: number = 0;
+
+ private maxSpeedCache: Map = new Map();
+
+ /**
+ *
+ * @param parameters
+ * @param aircraftDistanceAlongTrack
+ * @param climbSpeedConstraints - This should be sorted in increasing distance along track
+ * @param descentSpeedConstraints - This should be sorted in increasing distance along track
+ */
+ constructor(
+ private parameters: VerticalProfileComputationParametersObserver,
+ private aircraftDistanceAlongTrack: NauticalMiles,
+ private climbSpeedConstraints: MaxSpeedConstraint[],
+ private descentSpeedConstraints: MaxSpeedConstraint[],
+ ) { }
+
+ private isValidSpeedLimit(): boolean {
+ const { speed, underAltitude } = this.parameters.get().climbSpeedLimit;
+
+ return Number.isFinite(speed) && Number.isFinite(underAltitude);
+ }
+
+ getTarget(distanceFromStart: NauticalMiles, altitude: Feet, managedSpeedType: ManagedSpeedType): Knots {
+ const { fcuSpeed, flightPhase, preselectedClbSpeed } = this.parameters.get();
+
+ const hasPreselectedSpeed = flightPhase < FmgcFlightPhase.Climb && preselectedClbSpeed > 1;
+ // In the descent, the MCDU assumes an immediate return to managed speed, and selecting a speed should not affect the profile
+ const hasSelectedSpeed = fcuSpeed > 100 && flightPhase > FmgcFlightPhase.Takeoff && flightPhase < FlightPhase.FLIGHT_PHASE_DESCENT;
+
+ if (!hasPreselectedSpeed && !hasSelectedSpeed) {
+ return this.getManagedTarget(distanceFromStart, altitude, managedSpeedType);
+ }
+
+ const nextSpeedChange = this.findDistanceAlongTrackOfNextSpeedChange(this.aircraftDistanceAlongTrack);
+
+ if (distanceFromStart > nextSpeedChange) {
+ return this.getManagedTarget(distanceFromStart, altitude, managedSpeedType);
+ }
+
+ if (hasPreselectedSpeed) {
+ return preselectedClbSpeed;
+ }
+
+ return fcuSpeed;
+ }
+
+ getTargetWithoutConstraints(altitude: Feet, managedSpeedType: ManagedSpeedType) {
+ let managedSpeed = this.getManagedSpeedForType(managedSpeedType);
+ const { speed, underAltitude } = this.parameters.get().climbSpeedLimit;
+
+ if (this.isValidSpeedLimit() && altitude < underAltitude) {
+ managedSpeed = Math.min(speed, managedSpeed);
+ }
+
+ return managedSpeed;
+ }
+
+ private getManagedTarget(distanceFromStart: NauticalMiles, altitude: Feet, managedSpeedType: ManagedSpeedType): Knots {
+ let managedSpeed = this.getManagedSpeedForType(managedSpeedType);
+ const { speed, underAltitude } = this.parameters.get().climbSpeedLimit;
+
+ if (this.isValidSpeedLimit() && altitude < underAltitude) {
+ managedSpeed = Math.min(speed, managedSpeed);
+ }
+
+ return Math.min(managedSpeed, this.findMaxSpeedAtDistanceAlongTrack(distanceFromStart));
+ }
+
+ getCurrentSpeedTarget(): Knots {
+ return this.findMaxSpeedAtDistanceAlongTrack(this.aircraftDistanceAlongTrack);
+ }
+
+ private findMaxSpeedAtDistanceAlongTrack(distanceAlongTrack: NauticalMiles): Knots {
+ this.maxSpeedLookups++;
+
+ const cachedMaxSpeed = this.maxSpeedCache.get(distanceAlongTrack);
+ if (cachedMaxSpeed) {
+ this.maxSpeedCacheHits++;
+ return cachedMaxSpeed;
+ }
+
+ const maxSpeed = Math.min(
+ constraintToSpeed(this.getMaxClimbSpeedConstraint(distanceAlongTrack)),
+ constraintToSpeed(this.getMaxDescentSpeedConstraint(distanceAlongTrack)),
+ );
+
+ this.maxSpeedCache.set(distanceAlongTrack, maxSpeed);
+
+ return maxSpeed;
+ }
+
+ getMaxClimbSpeedConstraint(distanceAlongTrack: NauticalMiles): MaxSpeedConstraint {
+ let activeConstraint: MaxSpeedConstraint = null;
+
+ for (const constraint of this.climbSpeedConstraints) {
+ if (distanceAlongTrack < constraint.distanceFromStart && constraint.maxSpeed < constraintToSpeed(activeConstraint)) {
+ activeConstraint = constraint;
+ }
+ }
+
+ return activeConstraint;
+ }
+
+ getMaxDescentSpeedConstraint(distanceAlongTrack: NauticalMiles): MaxSpeedConstraint {
+ let activeConstraint: MaxSpeedConstraint = null;
+
+ // TODO: I think this is unnecessarily complex, we can probably just return the first constraint that is in front of us.
+ for (const constraint of this.descentSpeedConstraints) {
+ // Since the constraint are ordered, there is no need to search further
+ if (distanceAlongTrack < constraint.distanceFromStart) {
+ return activeConstraint;
+ }
+
+ activeConstraint = constraint;
+ }
+
+ return activeConstraint;
+ }
+
+ private findDistanceAlongTrackOfNextSpeedChange(distanceAlongTrack: NauticalMiles) {
+ let distance = Infinity;
+
+ for (const constraint of this.climbSpeedConstraints) {
+ if (distanceAlongTrack <= constraint.distanceFromStart && constraint.distanceFromStart < distance) {
+ distance = constraint.distanceFromStart;
+ }
+ }
+
+ // TODO: Handle speed limit
+
+ return distance;
+ }
+
+ showDebugStats() {
+ if (this.maxSpeedLookups === 0) {
+ console.log('[FMS/VNAV] No max speed lookups done so far.');
+ return;
+ }
+
+ console.log(
+ `[FMS/VNAV] Performed ${this.maxSpeedLookups} max speed lookups. Of whics ${this.maxSpeedCacheHits} (${100 * this.maxSpeedCacheHits / this.maxSpeedLookups}%) had been cached`,
+ );
+ }
+
+ shouldTakeSpeedLimitIntoAccount(): boolean {
+ return this.isValidSpeedLimit();
+ }
+
+ private getManagedSpeedForType(managedSpeedType: ManagedSpeedType) {
+ const { managedClimbSpeed, managedCruiseSpeed, managedDescentSpeed } = this.parameters.get();
+
+ switch (managedSpeedType) {
+ case ManagedSpeedType.Climb:
+ return managedClimbSpeed;
+ case ManagedSpeedType.Cruise:
+ return managedCruiseSpeed;
+ case ManagedSpeedType.Descent:
+ return managedDescentSpeed;
+ default:
+ throw new Error(`[FMS/VNAV] Invalid managedSpeedType: ${managedSpeedType}`);
+ }
+ }
+}
+
+export class ExpediteSpeedProfile implements SpeedProfile {
+ constructor(private greenDotSpeed: Knots) { }
+
+ getTarget(_distanceFromStart: number, _altitude: number): Knots {
+ return this.greenDotSpeed;
+ }
+
+ getTargetWithoutConstraints(_altitude: number, _managedSpeedType: ManagedSpeedType): number {
+ return this.greenDotSpeed;
+ }
+
+ getCurrentSpeedTarget(): Knots {
+ return Infinity;
+ }
+
+ shouldTakeSpeedLimitIntoAccount(): boolean {
+ return false;
+ }
+
+ getMaxClimbSpeedConstraint(_distanceFromStart: number): MaxSpeedConstraint {
+ return null;
+ }
+
+ getMaxDescentSpeedConstraint(_distanceFromStart: number): MaxSpeedConstraint {
+ return null;
+ }
+}
+
+/**
+ * The NdSpeedProfile is different from the MCDU speed profile because it assumes a selected speed is
+ * held until the end of the flight phase rather than only until the next speed constraint
+ */
+export class NdSpeedProfile implements SpeedProfile {
+ private maxSpeedCacheHits: number = 0;
+
+ private maxSpeedLookups: number = 0;
+
+ private maxSpeedCache: Map = new Map();
+
+ constructor(
+ private parameters: VerticalProfileComputationParametersObserver,
+ private aircraftDistanceAlongTrack: NauticalMiles,
+ private maxSpeedConstraints: MaxSpeedConstraint[],
+ private descentSpeedConstraints: MaxSpeedConstraint[],
+ ) { }
+
+ private isValidSpeedLimit(): boolean {
+ const { speed, underAltitude } = this.parameters.get().climbSpeedLimit;
+
+ return Number.isFinite(speed) && Number.isFinite(underAltitude);
+ }
+
+ getTarget(distanceFromStart: NauticalMiles, altitude: Feet, managedSpeedType: ManagedSpeedType): Knots {
+ const { fcuSpeed, flightPhase, preselectedClbSpeed } = this.parameters.get();
+
+ const hasPreselectedSpeed = flightPhase < FmgcFlightPhase.Climb && preselectedClbSpeed > 1;
+ const hasSelectedSpeed = fcuSpeed > 100 && flightPhase > FmgcFlightPhase.Takeoff;
+
+ if (hasPreselectedSpeed) {
+ return preselectedClbSpeed;
+ }
+
+ if (hasSelectedSpeed) {
+ return fcuSpeed;
+ }
+
+ return this.getManaged(distanceFromStart, altitude, managedSpeedType);
+ }
+
+ getTargetWithoutConstraints(altitude: Feet, managedSpeedType: ManagedSpeedType) {
+ let managedSpeed = this.getManagedSpeedForType(managedSpeedType);
+ const { speed, underAltitude } = this.parameters.get().climbSpeedLimit;
+
+ if (this.isValidSpeedLimit() && altitude < underAltitude) {
+ managedSpeed = Math.min(speed, managedSpeed);
+ }
+
+ return managedSpeed;
+ }
+
+ private getManaged(distanceFromStart: NauticalMiles, altitude: Feet, managedSpeedType: ManagedSpeedType): Knots {
+ let managedSpeed = this.getManagedSpeedForType(managedSpeedType);
+ const { speed, underAltitude } = this.parameters.get().climbSpeedLimit;
+
+ if (this.isValidSpeedLimit() && altitude < underAltitude) {
+ managedSpeed = Math.min(speed, managedSpeed);
+ }
+
+ return Math.min(managedSpeed, this.findMaxSpeedAtDistanceAlongTrack(distanceFromStart));
+ }
+
+ getCurrentSpeedTarget(): Knots {
+ return this.findMaxSpeedAtDistanceAlongTrack(this.aircraftDistanceAlongTrack);
+ }
+
+ isSelectedSpeed(): boolean {
+ const { fcuSpeed, flightPhase, preselectedClbSpeed } = this.parameters.get();
+
+ const hasPreselectedSpeed = flightPhase < FmgcFlightPhase.Climb && preselectedClbSpeed > 1;
+ const hasSelectedSpeed = fcuSpeed > 100 && flightPhase > FmgcFlightPhase.Takeoff;
+
+ return hasSelectedSpeed || hasPreselectedSpeed;
+ }
+
+ private findMaxSpeedAtDistanceAlongTrack(distanceAlongTrack: NauticalMiles): Knots {
+ this.maxSpeedLookups++;
+
+ const cachedMaxSpeed = this.maxSpeedCache.get(distanceAlongTrack);
+ if (cachedMaxSpeed) {
+ this.maxSpeedCacheHits++;
+ return cachedMaxSpeed;
+ }
+
+ const maxSpeed = Math.min(
+ constraintToSpeed(this.getMaxClimbSpeedConstraint(distanceAlongTrack)),
+ constraintToSpeed(this.findMaxDescentSpeedConstraint(distanceAlongTrack)),
+ );
+ this.maxSpeedCache.set(distanceAlongTrack, maxSpeed);
+
+ return maxSpeed;
+ }
+
+ getMaxClimbSpeedConstraint(distanceAlongTrack: NauticalMiles): MaxSpeedConstraint {
+ let activeConstraint: MaxSpeedConstraint = null;
+
+ for (const constraint of this.maxSpeedConstraints) {
+ if (distanceAlongTrack < constraint.distanceFromStart && constraint.maxSpeed < constraintToSpeed(activeConstraint)) {
+ activeConstraint = constraint;
+ }
+ }
+
+ return activeConstraint;
+ }
+
+ getMaxDescentSpeedConstraint(distanceAlongTrack: NauticalMiles): MaxSpeedConstraint {
+ let activeConstraint: MaxSpeedConstraint = null;
+
+ // TODO: I think this is unnecessarily complex, we can probably just return the first constraint that is in front of us.
+ for (const constraint of this.descentSpeedConstraints) {
+ // Since the constraint are ordered, there is no need to search further
+ if (distanceAlongTrack < constraint.distanceFromStart) {
+ return activeConstraint;
+ }
+
+ activeConstraint = constraint;
+ }
+
+ return activeConstraint;
+ }
+
+ private findMaxDescentSpeedConstraint(distanceAlongTrack: NauticalMiles): MaxSpeedConstraint {
+ let activeConstraint: MaxSpeedConstraint = null;
+
+ // TODO: I think this is unnecessarily complex, we can probably just return the first constraint that is in front of us.
+ for (const constraint of this.descentSpeedConstraints) {
+ // Since the constraint are ordered, there is no need to search further
+ if (distanceAlongTrack < constraint.distanceFromStart) {
+ return activeConstraint;
+ }
+
+ activeConstraint = constraint;
+ }
+
+ return activeConstraint;
+ }
+
+ showDebugStats() {
+ if (this.maxSpeedLookups === 0) {
+ console.log('[FMS/VNAV] No max speed lookups done so far.');
+ return;
+ }
+
+ console.log(
+ `[FMS/VNAV] Performed ${this.maxSpeedLookups} max speed lookups. Of which ${this.maxSpeedCacheHits} (${100 * this.maxSpeedCacheHits / this.maxSpeedLookups}%) had been cached`,
+ );
+ }
+
+ shouldTakeSpeedLimitIntoAccount(): boolean {
+ return this.isValidSpeedLimit() && !this.isSelectedSpeed();
+ }
+
+ private getManagedSpeedForType(managedSpeedType: ManagedSpeedType) {
+ const { managedClimbSpeed, managedCruiseSpeed, managedDescentSpeed } = this.parameters.get();
+
+ switch (managedSpeedType) {
+ case ManagedSpeedType.Climb:
+ return managedClimbSpeed;
+ case ManagedSpeedType.Cruise:
+ return managedCruiseSpeed;
+ case ManagedSpeedType.Descent:
+ return managedDescentSpeed;
+ default:
+ throw new Error(`[FMS/VNAV] Invalid managedSpeedType: ${managedSpeedType}`);
+ }
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/common.ts b/src/fmgc/src/guidance/vnav/common.ts
index 44c24b6e1876..c29e4ffac1c6 100644
--- a/src/fmgc/src/guidance/vnav/common.ts
+++ b/src/fmgc/src/guidance/vnav/common.ts
@@ -1,11 +1,11 @@
import { AltitudeConstraint, SpeedConstraint } from '../lnav/legs';
export enum FlapConf {
- CLEAN,
- CONF_1,
- CONF_2,
- CONF_3,
- CONF_FULL
+ CLEAN = 0,
+ CONF_1 = 1,
+ CONF_2 = 2,
+ CONF_3 = 3,
+ CONF_FULL = 4,
}
export enum AccelFactorMode {
@@ -81,7 +81,7 @@ export class Common {
}
/**
- * Get pressure ratio for a particular theta
+ * Get pressure ratio for a particular altitude
* @param alt pressure altitude
* @param aboveTropo whether the aircraft is above the tropopause
* @returns pressure ratio
@@ -126,7 +126,7 @@ export class Common {
static machToCas(mach: number, delta: number): number {
const term1 = (0.2 * mach ** 2 + 1) ** 3.5;
- const term2 = (delta * term1 + 1) ** (1 / 3.5) - 1;
+ const term2 = (delta * (term1 - 1) + 1) ** (1 / 3.5) - 1;
return 1479.1 * Math.sqrt(term2);
}
@@ -156,14 +156,16 @@ export class Common {
if (aboveTropo) {
return 1 + 0.7 * (mach ** 2) * phi;
}
+
return 1 + 0.7 * (mach ** 2) * (phi - 0.190263 * tempRatio);
}
static getAccelFactorMach(mach: number, aboveTropo: boolean, tempRatio?: number): number {
if (aboveTropo) {
- return 0;
+ return 1;
}
- return -0.13318 * (mach ** 2) * tempRatio;
+
+ return 1 - 0.13318 * (mach ** 2) * tempRatio;
}
/**
@@ -182,9 +184,9 @@ export class Common {
aboveTropo: boolean,
accelFactorMode: AccelFactorMode,
): number {
- const stdTemp = Common.getIsaTemp(altitude, aboveTropo);
- const temp = Common.getTemp(altitude, isaDev, aboveTropo);
- const tempRatio = stdTemp / temp;
+ // This is T_ISA / T, the ratio between ISA temperature at that altitude and the actual temperature at that altitude
+ const tempRatio = (273.15 + this.getIsaTemp(altitude, aboveTropo)) / (273.15 + this.getTemp(altitude, isaDev, aboveTropo));
+
if (accelFactorMode === AccelFactorMode.CONSTANT_CAS) {
return Common.getAccelFactorCAS(mach, aboveTropo, tempRatio);
}
diff --git a/src/fmgc/src/guidance/vnav/cruise/CruisePathBuilder.ts b/src/fmgc/src/guidance/vnav/cruise/CruisePathBuilder.ts
new file mode 100644
index 000000000000..2940ef6e7fdc
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/cruise/CruisePathBuilder.ts
@@ -0,0 +1,200 @@
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { Step, StepCoordinator } from '@fmgc/guidance/vnav/StepCoordinator';
+import { VnavConfig } from '@fmgc/guidance/vnav/VnavConfig';
+import { ClimbStrategy } from '@fmgc/guidance/vnav/climb/ClimbStrategy';
+import { DescentStrategy } from '@fmgc/guidance/vnav/descent/DescentStrategy';
+import { BaseGeometryProfile } from '@fmgc/guidance/vnav/profile/BaseGeometryProfile';
+import { ManagedSpeedType, SpeedProfile } from '@fmgc/guidance/vnav/climb/SpeedProfile';
+import { EngineModel } from '@fmgc/guidance/vnav/EngineModel';
+import { WindComponent } from '@fmgc/guidance/vnav/wind';
+import { TemporaryCheckpointSequence } from '@fmgc/guidance/vnav/profile/TemporaryCheckpointSequence';
+import { HeadwindProfile } from '@fmgc/guidance/vnav/wind/HeadwindProfile';
+import { Predictions, StepResults } from '../Predictions';
+import { MaxSpeedConstraint, VerticalCheckpoint, VerticalCheckpointReason } from '../profile/NavGeometryProfile';
+import { AtmosphericConditions } from '../AtmosphericConditions';
+
+export class CruisePathBuilder {
+ constructor(private computationParametersObserver: VerticalProfileComputationParametersObserver,
+ private atmosphericConditions: AtmosphericConditions,
+ private stepCoordinator: StepCoordinator) { }
+
+ computeCruisePath(
+ profile: BaseGeometryProfile,
+ startOfCruise: VerticalCheckpoint,
+ targetDistanceFromStart: NauticalMiles,
+ stepClimbStrategy: ClimbStrategy,
+ stepDescentStrategy: DescentStrategy,
+ speedProfile: SpeedProfile,
+ windProfile: HeadwindProfile,
+ ): TemporaryCheckpointSequence {
+ const sequence = new TemporaryCheckpointSequence(startOfCruise);
+
+ for (const step of this.stepCoordinator.steps) {
+ // If the step is too close to T/D
+ if (step.isIgnored) {
+ continue;
+ }
+
+ if (step.distanceFromStart < startOfCruise.distanceFromStart || step.distanceFromStart > targetDistanceFromStart) {
+ if (VnavConfig.DEBUG_PROFILE) {
+ console.warn(
+ `[FMS/VNAV] Cruise step is not within cruise segment \
+ (${step.distanceFromStart.toFixed(2)} NM, T/C: ${startOfCruise.distanceFromStart.toFixed(2)} NM, T/D: ${targetDistanceFromStart.toFixed(2)} NM)`,
+ );
+ }
+
+ continue;
+ }
+
+ // See if there are any speed constraints before the step
+ for (const speedConstraint of profile.maxClimbSpeedConstraints) {
+ if (speedConstraint.distanceFromStart > step.distanceFromStart) {
+ continue;
+ }
+
+ this.addSegmentToSpeedConstraint(sequence, speedConstraint, speedProfile, windProfile);
+ }
+
+ const { distanceFromStart, altitude, remainingFuelOnBoard } = sequence.lastCheckpoint;
+
+ const speed = speedProfile.getTarget(distanceFromStart, altitude, ManagedSpeedType.Cruise);
+ const headwind = windProfile.getHeadwindComponent(distanceFromStart, altitude);
+ const segmentToStep = this.computeCruiseSegment(step.distanceFromStart - distanceFromStart, remainingFuelOnBoard, speed, headwind);
+ sequence.addCheckpointFromStep(segmentToStep, VerticalCheckpointReason.AtmosphericConditions);
+
+ this.addStepFromLastCheckpoint(sequence, step, stepClimbStrategy, stepDescentStrategy);
+ }
+
+ // Once again, we check if there are any speed constraints before the T/D
+ for (const speedConstraint of profile.maxClimbSpeedConstraints) {
+ // If speed constraint does not lie along the remaining cruise track
+ if (speedConstraint.distanceFromStart > targetDistanceFromStart) {
+ continue;
+ }
+
+ this.addSegmentToSpeedConstraint(sequence, speedConstraint, speedProfile, windProfile);
+ }
+
+ const speedTarget = speedProfile.getTarget(
+ sequence.lastCheckpoint.distanceFromStart,
+ sequence.lastCheckpoint.altitude,
+ ManagedSpeedType.Cruise,
+ );
+
+ if (speedTarget - sequence.lastCheckpoint.speed > 1) {
+ const accelerationStep = this.levelAccelerationStep(
+ sequence.lastCheckpoint.distanceFromStart,
+ sequence.lastCheckpoint.speed,
+ speedTarget,
+ windProfile.getHeadwindComponent(sequence.lastCheckpoint.distanceFromStart, sequence.lastCheckpoint.distanceFromStart),
+ );
+
+ sequence.addCheckpointFromStep(accelerationStep, VerticalCheckpointReason.AtmosphericConditions);
+ }
+
+ if (targetDistanceFromStart < sequence.lastCheckpoint.distanceFromStart) {
+ console.warn('[FMS/VNAV] An acceleration step in the cruise took us past T/D. This is not implemented properly yet. Blame BBK');
+ }
+
+ const step = this.computeCruiseSegment(
+ targetDistanceFromStart - sequence.lastCheckpoint.distanceFromStart,
+ startOfCruise.remainingFuelOnBoard,
+ speedTarget,
+ windProfile.getHeadwindComponent(sequence.lastCheckpoint.distanceFromStart, sequence.lastCheckpoint.altitude),
+ );
+
+ sequence.addCheckpointFromStep(step, VerticalCheckpointReason.AtmosphericConditions);
+
+ return sequence;
+ }
+
+ private addSegmentToSpeedConstraint(sequence: TemporaryCheckpointSequence, speedConstraint: MaxSpeedConstraint, speedProfile: SpeedProfile, windProfile: HeadwindProfile) {
+ const { distanceFromStart, altitude, remainingFuelOnBoard } = sequence.lastCheckpoint;
+
+ if (speedConstraint.distanceFromStart < distanceFromStart) {
+ return;
+ }
+
+ const speed = speedProfile.getTarget(distanceFromStart, altitude, ManagedSpeedType.Cruise);
+ const segmentResult = this.computeCruiseSegment(
+ speedConstraint.distanceFromStart - distanceFromStart,
+ remainingFuelOnBoard,
+ speed,
+ windProfile.getHeadwindComponent(distanceFromStart, altitude),
+ );
+
+ sequence.addCheckpointFromStep(segmentResult, VerticalCheckpointReason.SpeedConstraint);
+ }
+
+ private addStepFromLastCheckpoint(sequence: TemporaryCheckpointSequence, step: Step, stepClimbStrategy: ClimbStrategy, stepDescentStrategy: DescentStrategy) {
+ // TODO: What happens if the step is at cruise altitude?
+ const { managedCruiseSpeed, managedCruiseSpeedMach } = this.computationParametersObserver.get();
+ const { altitude, remainingFuelOnBoard } = sequence.lastCheckpoint;
+
+ const isClimbVsDescent = step.toAltitude > altitude;
+ // Instead of just atmospheric conditions, the last checkpoint is now a step climb point
+ if (sequence.lastCheckpoint.reason === VerticalCheckpointReason.AtmosphericConditions) {
+ sequence.lastCheckpoint.reason = isClimbVsDescent
+ ? VerticalCheckpointReason.StepClimb
+ : VerticalCheckpointReason.StepDescent;
+ }
+
+ const stepResults = isClimbVsDescent
+ ? stepClimbStrategy.predictToAltitude(altitude, step.toAltitude, managedCruiseSpeed, managedCruiseSpeedMach, remainingFuelOnBoard, WindComponent.zero())
+ : stepDescentStrategy.predictToAltitude(altitude, step.toAltitude, managedCruiseSpeed, managedCruiseSpeed, remainingFuelOnBoard, WindComponent.zero());
+
+ sequence.addCheckpointFromStep(stepResults, isClimbVsDescent ? VerticalCheckpointReason.TopOfStepClimb : VerticalCheckpointReason.BottomOfStepDescent);
+ }
+
+ private computeCruiseSegment(distance: NauticalMiles, remainingFuelOnBoard: number, speed: Knots, headwind: WindComponent): StepResults {
+ const { zeroFuelWeight, cruiseAltitude, managedCruiseSpeedMach } = this.computationParametersObserver.get();
+
+ return Predictions.levelFlightStep(
+ cruiseAltitude,
+ distance,
+ speed,
+ managedCruiseSpeedMach,
+ zeroFuelWeight,
+ remainingFuelOnBoard,
+ headwind.value,
+ this.atmosphericConditions.isaDeviation,
+ );
+ }
+
+ private levelAccelerationStep(remainingFuelOnBoard: number, speed: Knots, finalSpeed: Knots, headwind: WindComponent): StepResults {
+ const { zeroFuelWeight, cruiseAltitude, managedCruiseSpeedMach, tropoPause } = this.computationParametersObserver.get();
+
+ return Predictions.speedChangeStep(
+ 0,
+ cruiseAltitude,
+ speed,
+ finalSpeed,
+ managedCruiseSpeedMach,
+ managedCruiseSpeedMach,
+ this.getClimbThrustN1Limit(this.atmosphericConditions, cruiseAltitude, speed),
+ zeroFuelWeight,
+ remainingFuelOnBoard,
+ headwind.value,
+ this.atmosphericConditions.isaDeviation,
+ tropoPause,
+ );
+ }
+
+ getFinalCruiseAltitude(): Feet {
+ const { cruiseAltitude } = this.computationParametersObserver.get();
+
+ if (this.stepCoordinator.steps.length === 0) {
+ return cruiseAltitude;
+ }
+
+ return this.stepCoordinator.steps[this.stepCoordinator.steps.length - 1].toAltitude;
+ }
+
+ private getClimbThrustN1Limit(atmosphericConditions: AtmosphericConditions, altitude: Feet, speed: Knots) {
+ // This Mach number is the Mach number for the predicted climb speed, not the Mach to use after crossover altitude.
+ const climbSpeedMach = atmosphericConditions.computeMachFromCas(altitude, speed);
+ const estimatedTat = atmosphericConditions.totalAirTemperatureFromMach(altitude, climbSpeedMach);
+
+ return EngineModel.tableInterpolation(EngineModel.maxClimbThrustTableLeap, estimatedTat, altitude);
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/descent/AircraftToProfileRelation.ts b/src/fmgc/src/guidance/vnav/descent/AircraftToProfileRelation.ts
new file mode 100644
index 000000000000..6c20d0af411d
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/descent/AircraftToProfileRelation.ts
@@ -0,0 +1,106 @@
+import { InertialDistanceAlongTrack } from '@fmgc/guidance/vnav/descent/InertialDistanceAlongTrack';
+import { NavGeometryProfile, VerticalCheckpoint, VerticalCheckpointReason } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { VnavConfig } from '@fmgc/guidance/vnav/VnavConfig';
+import { MathUtils } from '@shared/MathUtils';
+
+export class AircraftToDescentProfileRelation {
+ public isValid: boolean = false;
+
+ private currentProfile?: NavGeometryProfile;
+
+ private inertialDistanceAlongTrack: InertialDistanceAlongTrack;
+
+ private topOfDescent?: VerticalCheckpoint;
+
+ private geometricPathStart?: VerticalCheckpoint;
+
+ constructor(private observer: VerticalProfileComputationParametersObserver) {
+ this.inertialDistanceAlongTrack = new InertialDistanceAlongTrack();
+ }
+
+ updateProfile(profile: NavGeometryProfile) {
+ const topOfDescent = profile?.findVerticalCheckpoint(VerticalCheckpointReason.TopOfDescent);
+ const lastPosition = profile?.findVerticalCheckpoint(VerticalCheckpointReason.PresentPosition) ?? profile.checkpoints[0];
+ const geometricPathStart = profile?.findVerticalCheckpoint(VerticalCheckpointReason.GeometricPathStart);
+
+ const isProfileValid = !!topOfDescent && !!lastPosition && !!geometricPathStart;
+
+ if (!isProfileValid) {
+ this.invalidate();
+
+ // If the profile is empty, we don't bother logging that it's invalid, because it probably just hasn't been computed yet.
+ if (VnavConfig.DEBUG_PROFILE && profile.checkpoints.length >= 0) {
+ console.warn('[FMS/VNAV] Invalid profile');
+ }
+
+ return;
+ }
+
+ this.isValid = isProfileValid;
+
+ this.topOfDescent = topOfDescent;
+ this.geometricPathStart = geometricPathStart;
+
+ // TODO: Remove this
+ profile.checkpoints = profile.checkpoints.filter(({ reason }) => reason !== VerticalCheckpointReason.PresentPosition);
+
+ this.currentProfile = profile;
+
+ this.inertialDistanceAlongTrack.updateCorrectInformation(lastPosition.distanceFromStart);
+ }
+
+ private invalidate() {
+ this.isValid = false;
+ this.currentProfile = undefined;
+ this.topOfDescent = undefined;
+ }
+
+ update() {
+ if (!this.isValid) {
+ return;
+ }
+
+ this.inertialDistanceAlongTrack.update();
+ }
+
+ isPastTopOfDescent(): boolean {
+ return !this.topOfDescent || this.inertialDistanceAlongTrack.get() > this.topOfDescent.distanceFromStart;
+ }
+
+ isOnGeometricPath(): boolean {
+ return this.inertialDistanceAlongTrack.get() > this.geometricPathStart.distanceFromStart;
+ }
+
+ computeLinearDeviation(): Feet {
+ const altitude = this.observer.get().presentPosition.alt;
+ const targetAltitude = this.currentTargetAltitude();
+
+ return altitude - targetAltitude;
+ }
+
+ currentTargetAltitude(): Feet {
+ return this.currentProfile.interpolateAltitudeAtDistance(this.inertialDistanceAlongTrack.get());
+ }
+
+ currentTargetSpeed(): Feet {
+ return this.currentProfile.findNextSpeedTarget(this.inertialDistanceAlongTrack.get());
+ }
+
+ currentTargetPathAngle(): Degrees {
+ return this.currentProfile.interpolatePathAngleAtDistance(this.inertialDistanceAlongTrack.get());
+ }
+
+ currentTargetVerticalSpeed(): FeetPerMinute {
+ const groundSpeed = SimVar.GetSimVarValue('GPS GROUND SPEED', 'Knots');
+
+ const knotsToFeetPerMinute = 101.269;
+ return knotsToFeetPerMinute * groundSpeed * Math.tan(this.currentTargetPathAngle() * MathUtils.DEGREES_TO_RADIANS);
+ }
+
+ isAboveSpeedLimitAltitude(): boolean {
+ const { presentPosition, descentSpeedLimit } = this.observer.get();
+
+ return presentPosition.alt > descentSpeedLimit?.underAltitude;
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/descent/ApproachPathBuilder.ts b/src/fmgc/src/guidance/vnav/descent/ApproachPathBuilder.ts
new file mode 100644
index 000000000000..c42f5902943d
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/descent/ApproachPathBuilder.ts
@@ -0,0 +1,395 @@
+// Copyright (c) 2021 FlyByWire Simulations
+// SPDX-License-Identifier: GPL-3.0
+
+import { Predictions, StepResults } from '@fmgc/guidance/vnav/Predictions';
+import { FlapConf } from '@fmgc/guidance/vnav/common';
+import { DescentAltitudeConstraint, NavGeometryProfile, VerticalCheckpoint, VerticalCheckpointReason } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions';
+import { ManagedSpeedType, SpeedProfile } from '@fmgc/guidance/vnav/climb/SpeedProfile';
+import { DescentStrategy, IdleDescentStrategy } from '@fmgc/guidance/vnav/descent/DescentStrategy';
+import { AltitudeConstraintType } from '@fmgc/guidance/lnav/legs';
+import { MathUtils } from '@shared/MathUtils';
+import { WindComponent } from '@fmgc/guidance/vnav/wind';
+import { TemporaryCheckpointSequence } from '@fmgc/guidance/vnav/profile/TemporaryCheckpointSequence';
+import { EngineModel } from '@fmgc/guidance/vnav/EngineModel';
+
+class FlapConfigurationProfile {
+ static getBySpeed(speed: Knots): FlapConf {
+ if (speed > 230) {
+ return FlapConf.CLEAN;
+ } if (speed > 200) {
+ return FlapConf.CONF_1;
+ } if (speed > 185) {
+ return FlapConf.CONF_2;
+ } if (speed > 177) {
+ return FlapConf.CONF_3;
+ }
+
+ return FlapConf.CONF_FULL;
+ }
+
+ static findNextExtensionSpeed(speed: Knots) {
+ if (speed < 177) {
+ return 177;
+ } if (speed < 185) {
+ return 185;
+ } if (speed < 200) {
+ return 200;
+ } if (speed < 230) {
+ return 230;
+ }
+
+ return Infinity;
+ }
+}
+
+export interface AircraftConfiguration {
+ flapConfig: FlapConf
+ speedbrakesExtended: boolean
+ gearExtended: boolean
+}
+
+export class AircraftConfigurationProfile {
+ static getBySpeed(speed: Knots): AircraftConfiguration {
+ return {
+ flapConfig: FlapConfigurationProfile.getBySpeed(speed),
+ speedbrakesExtended: false,
+ gearExtended: speed < 200, // Below 200 kts, you will have the flaps extended, so we assume the gear to be down
+ };
+ }
+}
+
+export class ApproachPathBuilder {
+ private idleStrategy: DescentStrategy
+
+ constructor(private observer: VerticalProfileComputationParametersObserver, private atmosphericConditions: AtmosphericConditions) {
+ this.idleStrategy = new IdleDescentStrategy(this.observer, this.atmosphericConditions);
+ }
+
+ computeApproachPath(
+ profile: NavGeometryProfile, speedProfile: SpeedProfile, estimatedFuelOnBoardAtDestination: number, estimatedSecondsFromPresentAtDestination: number,
+ ): TemporaryCheckpointSequence {
+ const { approachSpeed, managedDescentSpeedMach, zeroFuelWeight, tropoPause, destinationAirfieldElevation } = this.observer.get();
+
+ const approachConstraints = profile.descentAltitudeConstraints.slice().reverse();
+
+ if (!this.canCompute(profile)) {
+ throw new Error('[FMS/VNAV] Cannot compute approach path, make sure to check `canCompute` before calling `computeApproachPath`!');
+ }
+
+ // Find starting point for computation
+ // Use either last constraint with its alt or just place a point wherever the end of the track is
+ const finalAltitude = this.canUseLastAltConstraintAsStartingPoint(profile) ? approachConstraints[0].constraint.altitude1 : destinationAirfieldElevation;
+
+ const sequence = new TemporaryCheckpointSequence({
+ reason: VerticalCheckpointReason.Landing,
+ speed: approachSpeed,
+ distanceFromStart: profile.getDistanceFromStart(0),
+ altitude: this.canUseLastAltConstraintAsStartingPoint(profile) ? approachConstraints[0].constraint.altitude1 : destinationAirfieldElevation,
+ remainingFuelOnBoard: estimatedFuelOnBoardAtDestination,
+ secondsFromPresent: estimatedSecondsFromPresentAtDestination,
+ mach: managedDescentSpeedMach,
+ });
+
+ // 6076.12
+ const distanceForThreeDegreeApproach = 1000 / Math.tan(3 * MathUtils.DEGREES_TO_RADIANS) / 6076.12;
+
+ // Build final segment
+ const finalApproachStep = Predictions.geometricStep(
+ finalAltitude + 1000,
+ finalAltitude,
+ distanceForThreeDegreeApproach,
+ approachSpeed,
+ managedDescentSpeedMach,
+ zeroFuelWeight,
+ estimatedFuelOnBoardAtDestination,
+ this.atmosphericConditions.isaDeviation,
+ 0,
+ tropoPause,
+ true,
+ FlapConf.CONF_FULL,
+ );
+
+ sequence.addCheckpointFromStepBackwards(finalApproachStep, VerticalCheckpointReason.AtmosphericConditions);
+
+ // Assume idle thrust from there
+ for (const altitudeConstraint of approachConstraints) {
+ this.handleAltitudeConstraint(sequence, speedProfile, altitudeConstraint);
+
+ // If you're at or above your descent speed (taking speed limit into account, place the decel point)
+ if (sequence.lastCheckpoint.speed - speedProfile.getTargetWithoutConstraints(sequence.lastCheckpoint.altitude, ManagedSpeedType.Descent) > -1) {
+ break;
+ }
+ }
+
+ const speedTarget = speedProfile.getTarget(sequence.lastCheckpoint.distanceFromStart, sequence.lastCheckpoint.altitude, ManagedSpeedType.Descent);
+
+ if (speedTarget - sequence.lastCheckpoint.speed > 0.1) {
+ const decelerationToDescentSpeed = this.buildDecelerationPath(sequence, this.idleStrategy, speedProfile, 0);
+ sequence.push(...decelerationToDescentSpeed.get());
+ }
+
+ // There are cases where the decel point is not added when we handle the constraints above, in this case, we just add it here.
+ if (sequence.lastCheckpoint.reason !== VerticalCheckpointReason.Decel) {
+ sequence.copyLastCheckpoint({ reason: VerticalCheckpointReason.Decel });
+ }
+
+ return sequence;
+ }
+
+ private handleAltitudeConstraint(sequence: TemporaryCheckpointSequence, speedProfile: SpeedProfile, constraint: DescentAltitudeConstraint) {
+ // We compose this segment of two segments:
+ // A descent segment
+ // A level deceleration segment
+ // This is how they appear along the track
+
+ // Going in reverse:
+ // We try to choose make the deceleration segment just as long that we manage to make the descent part
+ const { managedDescentSpeedMach } = this.observer.get();
+ const { distanceFromStart, altitude } = sequence.lastCheckpoint;
+
+ if (distanceFromStart < constraint.distanceFromStart
+ || constraint.constraint.type === AltitudeConstraintType.atOrBelow
+ || altitude - constraint.constraint.altitude1 > -50 // If we are already above the constraint
+ ) {
+ return;
+ }
+
+ const minimumAltitude = constraint.constraint.type === AltitudeConstraintType.range
+ ? constraint.constraint.altitude2
+ : constraint.constraint.altitude1;
+
+ const desiredDistanceToCover = distanceFromStart - constraint.distanceFromStart;
+
+ let i = 0;
+ let decelerationSegmentDistance = 0;
+ let decelerationSegmentDistanceError = Infinity;
+
+ let decelerationSequence: TemporaryCheckpointSequence = null;
+ let descentSegment = null;
+
+ while (i++ < 4 && Math.abs(decelerationSegmentDistanceError) > 0.05) {
+ decelerationSequence = this.buildDecelerationPath(
+ sequence,
+ this.idleStrategy,
+ speedProfile,
+ distanceFromStart - decelerationSegmentDistance,
+ );
+
+ descentSegment = this.idleStrategy.predictToAltitude(
+ minimumAltitude,
+ altitude,
+ decelerationSequence.lastCheckpoint.speed,
+ managedDescentSpeedMach,
+ decelerationSequence.lastCheckpoint.remainingFuelOnBoard,
+ WindComponent.zero(),
+ AircraftConfigurationProfile.getBySpeed(decelerationSequence.lastCheckpoint.speed),
+ );
+
+ const distanceTraveled = descentSegment.distanceTraveled + (distanceFromStart - decelerationSequence.lastCheckpoint.distanceFromStart);
+
+ decelerationSegmentDistanceError = distanceTraveled - desiredDistanceToCover;
+ decelerationSegmentDistance = Math.max(0, decelerationSegmentDistance - decelerationSegmentDistanceError);
+ }
+
+ sequence.push(...decelerationSequence.get());
+
+ // Don't bother considering the climb step in the profile if we have already reached the target speed in the deceleration segment
+ if (speedProfile.getTargetWithoutConstraints(decelerationSequence.lastCheckpoint.altitude, ManagedSpeedType.Descent) - decelerationSequence.lastCheckpoint.speed > 1) {
+ sequence.addCheckpointFromStepBackwards(descentSegment, VerticalCheckpointReason.AltitudeConstraint);
+ }
+ }
+
+ canCompute(profile: NavGeometryProfile): boolean {
+ return this.canUseLastAltConstraintAsStartingPoint(profile)
+ || Number.isFinite(this.observer.get().destinationAirfieldElevation);
+ }
+
+ private canUseLastAltConstraintAsStartingPoint(profile: NavGeometryProfile): boolean {
+ if (profile.descentAltitudeConstraints.length < 1) {
+ return false;
+ }
+
+ const lastAltConstraint = profile.descentAltitudeConstraints[profile.descentAltitudeConstraints.length - 1];
+
+ return lastAltConstraint.constraint.type === AltitudeConstraintType.at && Math.abs(lastAltConstraint.distanceFromStart - profile.totalFlightPlanDistance) < 1;
+ }
+
+ private scaleStepBasedOnLastCheckpoint(lastCheckpoint: VerticalCheckpoint, step: StepResults, scaling: number) {
+ step.distanceTraveled *= scaling;
+ step.fuelBurned *= scaling;
+ step.timeElapsed *= scaling;
+ step.finalAltitude = (1 - scaling) * lastCheckpoint.altitude + scaling * step.initialAltitude;
+ step.speed = (1 - scaling) * lastCheckpoint.speed + scaling * step.speed;
+ }
+
+ /**
+ * This builds a level deceleration path, bringing out flaps as needed, and obeying speed constraints
+ * @param sequence
+ * @param strategy
+ * @param speedProfile
+ * @param targetDistanceFromStart
+ * @returns
+ */
+ private buildDecelerationPath(
+ sequence: TemporaryCheckpointSequence, strategy: DescentStrategy, speedProfile: SpeedProfile, targetDistanceFromStart: NauticalMiles,
+ ): TemporaryCheckpointSequence {
+ const decelerationSequence = new TemporaryCheckpointSequence(sequence.lastCheckpoint);
+
+ const { managedDescentSpeedMach } = this.observer.get();
+
+ let i = 0;
+ while (i++ < 10
+ && decelerationSequence.lastCheckpoint.reason !== VerticalCheckpointReason.Decel
+ && Math.abs(decelerationSequence.lastCheckpoint.distanceFromStart - targetDistanceFromStart) > 0.1
+ ) {
+ const { distanceFromStart, altitude, speed, remainingFuelOnBoard } = decelerationSequence.lastCheckpoint;
+
+ const speedConstraint = speedProfile.getMaxDescentSpeedConstraint(distanceFromStart - 1e-4);
+ const flapTargetSpeed = FlapConfigurationProfile.findNextExtensionSpeed(speed);
+
+ // This is the managed descent speed, or the speed limit speed.
+ const limitingSpeed = speedProfile.getTargetWithoutConstraints(decelerationSequence.lastCheckpoint.altitude, ManagedSpeedType.Descent);
+
+ // If the constraint is limiting, decelerate to the constraint, then fly constant speed until it is resolved
+ // If the flapTarget is limiting, decelerate to the flap target
+ // If the limitingSpeed is limiting, decelerate to it and return
+
+ // Constraint is constraining
+ if (speedConstraint !== null && speedConstraint.maxSpeed < flapTargetSpeed && speedConstraint.maxSpeed < limitingSpeed) {
+ const remainingDistance = distanceFromStart - Math.max(speedConstraint.distanceFromStart, targetDistanceFromStart);
+
+ // Decelerate to constraint
+ const decelerationStep = this.levelDecelerationSegment(
+ altitude,
+ speed,
+ speedConstraint.maxSpeed,
+ managedDescentSpeedMach,
+ remainingFuelOnBoard,
+ WindComponent.zero(),
+ AircraftConfigurationProfile.getBySpeed(speed),
+ );
+
+ if (decelerationStep.distanceTraveled > 1e-4) {
+ // We tried to declerate, but it took us beyond targetDistanceFromStart, so we scale down the step
+ const scaling = Math.min(1, remainingDistance / decelerationStep.distanceTraveled);
+ this.scaleStepBasedOnLastCheckpoint(decelerationSequence.lastCheckpoint, decelerationStep, scaling);
+ decelerationSequence.addCheckpointFromStepBackwards(decelerationStep, VerticalCheckpointReason.AtmosphericConditions);
+ }
+
+ const remainingDistanceToConstraint = distanceFromStart - decelerationStep.distanceTraveled - Math.max(speedConstraint.distanceFromStart, targetDistanceFromStart);
+
+ if (remainingDistanceToConstraint > 0.1) {
+ // If we decelerated, but aren't at the constraint yet, fly level, at constant speed to the constraint
+
+ const constantStep = this.levelFlightSegment(
+ altitude,
+ remainingDistanceToConstraint,
+ speedConstraint.maxSpeed,
+ managedDescentSpeedMach,
+ remainingFuelOnBoard - decelerationStep.fuelBurned,
+ WindComponent.zero(),
+ AircraftConfigurationProfile.getBySpeed(speedConstraint.maxSpeed),
+ );
+
+ decelerationSequence.addCheckpointFromStepBackwards(constantStep, VerticalCheckpointReason.SpeedConstraint);
+ } else {
+ decelerationSequence.copyLastCheckpoint({ reason: VerticalCheckpointReason.SpeedConstraint });
+ }
+ } else {
+ const remainingDistance = distanceFromStart - targetDistanceFromStart;
+ const speedTargetWithConstraints = speedProfile.getTarget(distanceFromStart, decelerationSequence.lastCheckpoint.altitude, ManagedSpeedType.Descent);
+
+ const targetSpeed = Math.min(flapTargetSpeed, speedTargetWithConstraints);
+ // flapTarget is constraining
+ const decelerationStep = this.levelDecelerationSegment(
+ altitude,
+ speed,
+ targetSpeed,
+ managedDescentSpeedMach,
+ remainingFuelOnBoard,
+ WindComponent.zero(),
+ AircraftConfigurationProfile.getBySpeed(speed),
+ );
+
+ if (decelerationStep.distanceTraveled > remainingDistance) {
+ const scaling = Math.min(1, remainingDistance / decelerationStep.distanceTraveled);
+ this.scaleStepBasedOnLastCheckpoint(decelerationSequence.lastCheckpoint, decelerationStep, scaling);
+ decelerationSequence.addCheckpointFromStepBackwards(decelerationStep, VerticalCheckpointReason.AtmosphericConditions);
+
+ return decelerationSequence;
+ }
+
+ decelerationSequence.addCheckpointFromStepBackwards(decelerationStep, this.getFlapCheckpointReasonByFlapConf(FlapConfigurationProfile.getBySpeed(targetSpeed)));
+ }
+ }
+
+ return decelerationSequence;
+ }
+
+ // TODO: This should not be here.
+ // It assumes idle thrust, which is correct, but I am using a dynamic DescentStrategy everywhere in this class except here.
+ private levelDecelerationSegment(
+ finalAltitude: number, finalSpeed: Knots, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent, config?: AircraftConfiguration,
+ ): StepResults {
+ const { zeroFuelWeight, tropoPause } = this.observer.get();
+ const actualMach = this.atmosphericConditions.computeMachFromCas(finalAltitude, (finalSpeed + speed) / 2);
+
+ const step = Predictions.speedChangeStep(
+ 0,
+ finalAltitude,
+ speed,
+ finalSpeed,
+ mach,
+ mach,
+ EngineModel.getIdleN1(finalAltitude, actualMach),
+ zeroFuelWeight,
+ fuelOnBoard,
+ headwindComponent.value,
+ this.atmosphericConditions.isaDeviation,
+ tropoPause,
+ config.gearExtended,
+ config.flapConfig,
+ );
+
+ // Change this to the initial speed as it comes back as the final speed, but we are trying to compute the profile backwards
+ step.speed = speed;
+
+ return step;
+ }
+
+ private levelFlightSegment(
+ altitude: Feet, distance: NauticalMiles, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent, config?: AircraftConfiguration,
+ ): StepResults {
+ const { zeroFuelWeight } = this.observer.get();
+
+ return Predictions.levelFlightStep(
+ altitude,
+ distance,
+ speed,
+ mach,
+ zeroFuelWeight,
+ fuelOnBoard,
+ headwindComponent.value,
+ this.atmosphericConditions.isaDeviation,
+ );
+ }
+
+ private getFlapCheckpointReasonByFlapConf(flapConfig: FlapConf) {
+ switch (flapConfig) {
+ case FlapConf.CONF_FULL:
+ return VerticalCheckpointReason.FlapsFull;
+ case FlapConf.CONF_3:
+ return VerticalCheckpointReason.Flaps3;
+ case FlapConf.CONF_2:
+ return VerticalCheckpointReason.Flaps2;
+ case FlapConf.CONF_1:
+ return VerticalCheckpointReason.Flaps1;
+ case FlapConf.CLEAN:
+ return VerticalCheckpointReason.Decel;
+ default:
+ throw new Error(`[FMS/VNAV] Unknown flap config: ${flapConfig}`);
+ }
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/descent/DecelPathBuilder.ts b/src/fmgc/src/guidance/vnav/descent/DecelPathBuilder.ts
index 74002d0bdc60..79a5e7489f00 100644
--- a/src/fmgc/src/guidance/vnav/descent/DecelPathBuilder.ts
+++ b/src/fmgc/src/guidance/vnav/descent/DecelPathBuilder.ts
@@ -5,6 +5,11 @@ import { Geometry } from '@fmgc/guidance/Geometry';
import { TFLeg } from '@fmgc/guidance/lnav/legs/TF';
import { Predictions, StepResults, VnavStepError } from '@fmgc/guidance/vnav/Predictions';
import { FlapConf } from '@fmgc/guidance/vnav/common';
+import { NavGeometryProfile, VerticalCheckpointReason } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { Leg } from '@fmgc/guidance/lnav/legs/Leg';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions';
+import { ManagedSpeedType, SpeedProfile } from '@fmgc/guidance/vnav/climb/SpeedProfile';
const ALTITUDE_ADJUSTMENT_FACTOR = 1.4;
@@ -29,9 +34,9 @@ export interface DecelPathCharacteristics {
}
export class DecelPathBuilder {
- static computeDecelPath(
- geometry: Geometry,
- ): DecelPathCharacteristics {
+ constructor(private observer: VerticalProfileComputationParametersObserver, private atmosphericConditions: AtmosphericConditions) { }
+
+ computeDecelPath(profile: NavGeometryProfile, speedProfile: SpeedProfile, estimatedFuelOnBoardAtDestination: number, estimatedSecondsFromPresentAtDestination: number) {
// TO GET FPA:
// If approach exists, use approach alt constraints to get FPA and glidepath
// If no approach but arrival, use arrival alt constraints, if any
@@ -43,79 +48,151 @@ export class DecelPathBuilder {
// - TODO: make sure alt constraints are obeyed during this speed change DECEL segment?
// The point at the beginning of the speedChangeStep is DECEL
- const TEMP_TROPO = 36_000;
- const TEMP_FUEL_WEIGHT = 2_300;
const DES = 250;
- const O = 203;
- const S = 184;
- const F = 143;
- const vappSegment = DecelPathBuilder.computeVappSegment(geometry);
+ const { approachSpeed, flapRetractionSpeed, slatRetractionSpeed, cleanSpeed, zeroFuelWeight, managedDescentSpeedMach, destinationAirfieldElevation } = this.observer.get();
+
+ if (!this.canCompute(profile.geometry, profile.waypointCount)) {
+ return;
+ }
+
+ const vappSegment = Predictions.geometricStep(
+ destinationAirfieldElevation + 1_000,
+ destinationAirfieldElevation + 50,
+ 3.14,
+ approachSpeed,
+ managedDescentSpeedMach,
+ zeroFuelWeight,
+ estimatedFuelOnBoardAtDestination,
+ 0,
+ this.atmosphericConditions.isaDeviation,
+ true,
+ FlapConf.CONF_FULL,
+ );
+
+ let timeElapsed = estimatedSecondsFromPresentAtDestination - vappSegment.timeElapsed;
+ let fuelWeight = estimatedFuelOnBoardAtDestination + vappSegment.fuelBurned;
+ let distanceFromEnd = vappSegment.distanceTraveled;
- let fuelWeight = TEMP_FUEL_WEIGHT;
+ profile.checkpoints.push({
+ reason: VerticalCheckpointReason.Landing,
+ distanceFromStart: profile.getDistanceFromStart(distanceFromEnd),
+ speed: approachSpeed, // FIXME
+ altitude: vappSegment.finalAltitude,
+ remainingFuelOnBoard: fuelWeight,
+ secondsFromPresent: timeElapsed,
+ });
const cFullTo3Segment = DecelPathBuilder.computeConfigurationChangeSegment(
ApproachPathSegmentType.CONSTANT_SLOPE,
-3,
- 1_000,
- F,
- 135,
+ destinationAirfieldElevation + 1_000,
+ flapRetractionSpeed,
+ approachSpeed,
fuelWeight,
FlapConf.CONF_FULL,
true,
- TEMP_TROPO,
+ this.atmosphericConditions.isaDeviation,
);
+
fuelWeight += cFullTo3Segment.fuelBurned;
+ distanceFromEnd += cFullTo3Segment.distanceTraveled;
+ timeElapsed -= cFullTo3Segment.timeElapsed;
+
+ profile.checkpoints.push({
+ reason: VerticalCheckpointReason.FlapsFull,
+ distanceFromStart: profile.getDistanceFromStart(distanceFromEnd),
+ speed: flapRetractionSpeed,
+ altitude: cFullTo3Segment.initialAltitude,
+ remainingFuelOnBoard: fuelWeight,
+ secondsFromPresent: timeElapsed,
+ });
const c3to2Segment = DecelPathBuilder.computeConfigurationChangeSegment(
ApproachPathSegmentType.CONSTANT_SLOPE,
-3,
cFullTo3Segment.initialAltitude,
- F + (S - F) / 2,
- F,
+ (flapRetractionSpeed + slatRetractionSpeed) / 2,
+ flapRetractionSpeed,
fuelWeight,
FlapConf.CONF_3,
true,
- TEMP_TROPO,
+ this.atmosphericConditions.isaDeviation,
);
+
fuelWeight += c3to2Segment.fuelBurned;
+ distanceFromEnd += c3to2Segment.distanceTraveled;
+ timeElapsed -= c3to2Segment.timeElapsed;
+
+ profile.checkpoints.push({
+ reason: VerticalCheckpointReason.Flaps3,
+ distanceFromStart: profile.getDistanceFromStart(distanceFromEnd),
+ speed: (flapRetractionSpeed + slatRetractionSpeed) / 2,
+ altitude: c3to2Segment.initialAltitude,
+ remainingFuelOnBoard: fuelWeight,
+ secondsFromPresent: timeElapsed,
+ });
const c2to1Segment = DecelPathBuilder.computeConfigurationChangeSegment(
ApproachPathSegmentType.CONSTANT_SLOPE,
-3,
c3to2Segment.initialAltitude,
- S,
- F + (S - F) / 2,
+ slatRetractionSpeed,
+ (flapRetractionSpeed + slatRetractionSpeed) / 2,
fuelWeight,
FlapConf.CONF_2,
false,
- TEMP_TROPO,
+ this.atmosphericConditions.isaDeviation,
);
+
fuelWeight += c2to1Segment.fuelBurned;
+ distanceFromEnd += c2to1Segment.distanceTraveled;
+ timeElapsed -= c2to1Segment.timeElapsed;
+
+ profile.checkpoints.push({
+ reason: VerticalCheckpointReason.Flaps2,
+ distanceFromStart: profile.getDistanceFromStart(distanceFromEnd),
+ speed: slatRetractionSpeed,
+ altitude: c2to1Segment.initialAltitude,
+ remainingFuelOnBoard: fuelWeight,
+ secondsFromPresent: timeElapsed,
+ });
const c1toCleanSegment = DecelPathBuilder.computeConfigurationChangeSegment(
ApproachPathSegmentType.CONSTANT_SLOPE,
-2.5,
c2to1Segment.initialAltitude,
- O,
- S,
+ cleanSpeed,
+ slatRetractionSpeed,
fuelWeight,
FlapConf.CONF_1,
false,
- TEMP_TROPO,
+ this.atmosphericConditions.isaDeviation,
);
+
fuelWeight += c1toCleanSegment.fuelBurned;
+ distanceFromEnd += c1toCleanSegment.distanceTraveled;
+ timeElapsed -= c1toCleanSegment.timeElapsed;
+
+ profile.checkpoints.push({
+ reason: VerticalCheckpointReason.Flaps1,
+ distanceFromStart: profile.getDistanceFromStart(distanceFromEnd),
+ speed: cleanSpeed,
+ altitude: c1toCleanSegment.initialAltitude,
+ remainingFuelOnBoard: fuelWeight,
+ secondsFromPresent: timeElapsed,
+ });
let cleanToDesSpeedSegment = DecelPathBuilder.computeConfigurationChangeSegment(
ApproachPathSegmentType.CONSTANT_SLOPE,
-2.5,
c1toCleanSegment.initialAltitude,
- DES,
- O,
+ speedProfile.getTarget(profile.getDistanceFromStart(distanceFromEnd), c1toCleanSegment.initialAltitude, ManagedSpeedType.Descent),
+ cleanSpeed,
fuelWeight,
FlapConf.CLEAN,
false,
- TEMP_TROPO,
+ this.atmosphericConditions.isaDeviation,
);
// TODO for TOO_LOW_DECELERATION do CONSTANT_DECELERATION, not LEVEL_DECELERATION
@@ -130,68 +207,30 @@ export class DecelPathBuilder {
ApproachPathSegmentType.LEVEL_DECELERATION,
undefined,
c1toCleanSegment.initialAltitude,
- DES,
- O,
+ speedProfile.getTarget(profile.getDistanceFromStart(distanceFromEnd), c1toCleanSegment.initialAltitude, ManagedSpeedType.Descent),
+ cleanSpeed,
fuelWeight,
FlapConf.CLEAN,
false,
- TEMP_TROPO,
+ this.atmosphericConditions.isaDeviation,
);
// } else {
// throw new Error('[VNAV/computeDecelPath] Computation of cleanToDesSpeedSegment for CDA is not yet implemented');
// }
}
- return {
- flap1: vappSegment.distanceTraveled
- + cFullTo3Segment.distanceTraveled
- + c3to2Segment.distanceTraveled
- + c2to1Segment.distanceTraveled
- + c1toCleanSegment.distanceTraveled,
- flap2: vappSegment.distanceTraveled
- + cFullTo3Segment.distanceTraveled
- + c3to2Segment.distanceTraveled
- + c2to1Segment.distanceTraveled,
- decel: vappSegment.distanceTraveled
- + cFullTo3Segment.distanceTraveled
- + c3to2Segment.distanceTraveled
- + c2to1Segment.distanceTraveled
- + c1toCleanSegment.distanceTraveled
- + cleanToDesSpeedSegment.distanceTraveled,
- top: cleanToDesSpeedSegment.finalAltitude,
- };
- }
-
- /**
- * Calculates the Vapp segment of the DECEL path.
- *
- * @return the Vapp segment step results
- */
- private static computeVappSegment(
- geometry: Geometry,
- ): StepResults {
- const TEMP_VAPP = 135; // TODO actual Vapp
-
- const finalAltitude = DecelPathBuilder.findLastApproachPoint(geometry);
-
- // TODO For now we use some "reasonable" values for the segment. When we have the ability to predict idle N1 and such at approach conditions,
- // we can change this.
- return {
- ...Predictions.altitudeStep(
- 1_000,
- -(1_000 - finalAltitude),
- TEMP_VAPP, // TODO placeholder value
- 999, // TODO placeholder value
- 26, // TODO placeholder value
- 107_000, // TODO placeholder value
- 5_000, // TODO placeholder value
- 2, // TODO placeholder value
- 0, // TODO placeholder value
- 36_000, // TODO placeholder value
- false, // TODO placeholder value
- ),
- distanceTraveled: 3.14, // FIXME hard-coded correct value for -3deg fpa
- };
+ fuelWeight += cleanToDesSpeedSegment.fuelBurned;
+ distanceFromEnd += cleanToDesSpeedSegment.distanceTraveled;
+ timeElapsed -= cleanToDesSpeedSegment.timeElapsed;
+
+ profile.checkpoints.push({
+ reason: VerticalCheckpointReason.Decel,
+ distanceFromStart: profile.getDistanceFromStart(distanceFromEnd),
+ speed: DES,
+ altitude: cleanToDesSpeedSegment.initialAltitude,
+ remainingFuelOnBoard: fuelWeight,
+ secondsFromPresent: timeElapsed,
+ });
}
/**
@@ -287,34 +326,65 @@ export class DecelPathBuilder {
case ApproachPathSegmentType.CONSTANT_SPEED:
throw new Error('[FMS/VNAV/computeConfigurationChangeSegment] CONSTANT_SPEED is not supported for configuration changes.');
case ApproachPathSegmentType.LEVEL_DECELERATION:
- return Predictions.speedChangeStep(
- 0,
- finalAltitude * ALTITUDE_ADJUSTMENT_FACTOR,
- fromSpeed,
- toSpeed,
- 999,
- 999,
- 26,
- 107_000,
- initialFuelWeight,
- 2,
- 0,
- tropoAltitude,
- gearExtended,
- newConfiguration,
- );
+ return {
+ ...Predictions.speedChangeStep(
+ 0,
+ finalAltitude * ALTITUDE_ADJUSTMENT_FACTOR,
+ fromSpeed,
+ toSpeed,
+ 999,
+ 999,
+ 26,
+ 107_000,
+ initialFuelWeight,
+ 2,
+ 0,
+ tropoAltitude,
+ gearExtended,
+ newConfiguration,
+ ),
+ initialAltitude: finalAltitude * ALTITUDE_ADJUSTMENT_FACTOR,
+ };
default:
throw new Error('[FMS/VNAV/computeConfigurationChangeSegment] Unknown segment type.');
}
}
+ /**
+ * Only compute if the last leg is a destination airport / runway
+ */
+ canCompute(geometry: Geometry, wptCount: number): boolean {
+ let lastLeg = geometry.legs.get(wptCount - 1);
+
+ // If somehow this wasn't the last leg, we find it the hard way.
+ if (!lastLeg) {
+ lastLeg = this.findLastLeg(geometry);
+ }
+
+ return lastLeg && lastLeg instanceof TFLeg && (lastLeg.to.isRunway || lastLeg.to.type === 'A');
+ }
+
+ findLastLeg(geometry: Geometry): Leg {
+ let lastLeg = undefined;
+ let maxIndex = -Infinity;
+
+ for (const [i, leg] of geometry.legs) {
+ if (i > maxIndex) {
+ lastLeg = leg;
+ maxIndex = i;
+ }
+ }
+
+ return lastLeg;
+ }
+
/**
* Returns altitude of either, in order of priority:
* - runway threshold;
* - missed approach point;
* - airport.
*/
- private static findLastApproachPoint(
+ private findLastApproachPoint(
geometry: Geometry,
): Feet {
const lastLeg = geometry.legs.get(geometry.legs.size - 1);
diff --git a/src/fmgc/src/guidance/vnav/descent/DescentBuilder.ts b/src/fmgc/src/guidance/vnav/descent/DescentBuilder.ts
deleted file mode 100644
index 7f3f0a348c30..000000000000
--- a/src/fmgc/src/guidance/vnav/descent/DescentBuilder.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { TheoreticalDescentPathCharacteristics } from '@fmgc/guidance/vnav/descent/TheoreticalDescentPath';
-import { Geometry } from '@fmgc/guidance/Geometry';
-import { DecelPathCharacteristics } from '@fmgc/guidance/vnav/descent/DecelPathBuilder';
-
-export class DescentBuilder {
- static computeDescentPath(
- geometry: Geometry,
- decelPath: DecelPathCharacteristics,
- ): TheoreticalDescentPathCharacteristics {
- const cruiseAlt = SimVar.GetSimVarValue('L:AIRLINER_CRUISE_ALTITUDE', 'number');
- const verticalDistance = cruiseAlt - decelPath.top;
- const fpa = 3;
-
- if (DEBUG) {
- console.log(cruiseAlt);
- console.log(verticalDistance);
- }
-
- const tod = decelPath.decel + (verticalDistance / Math.tan((fpa * Math.PI) / 180)) * 0.000164579;
-
- if (DEBUG) {
- console.log(`[FMS/VNAV] T/D: ${tod.toFixed(1)}nm`);
- }
-
- return { tod };
-
- // const decelPointDistance = DecelPathBuilder.computeDecelPath(geometry);
- //
- // const lastLegIndex = geometry.legs.size - 1;
- //
- // // Find descent legs before decel point
- // let accumulatedDistance = 0;
- // let currentLegIdx;
- // let currentLeg;
- // for (currentLegIdx = lastLegIndex; accumulatedDistance < decelPointDistance; currentLegIdx--) {
- // currentLeg = geometry.legs.get(currentLegIdx);
- //
- // accumulatedDistance += currentLeg.distance;
- // }
- // currentLegIdx--;
- //
- // const geometricPath = GeomtricPathBuilder.buildGeometricPath(geometry, currentLegIdx);
- //
- // console.log(geometricPath);
- //
- // return { geometricPath };
- // }
- }
-}
diff --git a/src/fmgc/src/guidance/vnav/descent/DescentGuidance.ts b/src/fmgc/src/guidance/vnav/descent/DescentGuidance.ts
new file mode 100644
index 000000000000..f24d8f98aa12
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/descent/DescentGuidance.ts
@@ -0,0 +1,289 @@
+import { RequestedVerticalMode, TargetAltitude, TargetVerticalSpeed } from '@fmgc/guidance/ControlLaws';
+import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions';
+import { AircraftToDescentProfileRelation } from '@fmgc/guidance/vnav/descent/AircraftToProfileRelation';
+import { NavGeometryProfile } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { VerticalMode } from '@shared/autopilot';
+import { FmgcFlightPhase } from '@shared/flightphase';
+import { VnavConfig } from '@fmgc/guidance/vnav/VnavConfig';
+import { SpeedMargin } from './SpeedMargin';
+
+enum DescentVerticalGuidanceState {
+ InvalidProfile,
+ ProvidingGuidance,
+ Observing
+}
+
+enum DescentSpeedGuidanceState {
+ NotInDescentPhase,
+ TargetOnly,
+ TargetAndMargins,
+}
+
+export class DescentGuidance {
+ private verticalState: DescentVerticalGuidanceState = DescentVerticalGuidanceState.InvalidProfile;
+
+ private speedState: DescentSpeedGuidanceState = DescentSpeedGuidanceState.NotInDescentPhase;
+
+ private requestedVerticalMode: RequestedVerticalMode = RequestedVerticalMode.None;
+
+ private targetAltitude: TargetAltitude = 0;
+
+ private targetAltitudeGuidance: TargetAltitude = 0;
+
+ private targetVerticalSpeed: TargetVerticalSpeed = 0;
+
+ private showLinearDeviationOnPfd: boolean = false;
+
+ private showDescentLatchOnPfd: boolean = false;
+
+ private showSpeedMargin: boolean = false;
+
+ private speedMargin: SpeedMargin;
+
+ private speedTarget: Knots | Mach;
+
+ private tdReached: boolean;
+
+ // 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
+ private isInOverspeedCondition: boolean = false;
+
+ private isInUnderspeedCondition: boolean = false;
+
+ constructor(
+ private aircraftToDescentProfileRelation: AircraftToDescentProfileRelation,
+ private observer: VerticalProfileComputationParametersObserver,
+ private atmosphericConditions: AtmosphericConditions,
+ ) {
+ this.speedMargin = new SpeedMargin(this.observer);
+
+ this.writeToSimVars();
+ }
+
+ updateProfile(profile: NavGeometryProfile) {
+ this.aircraftToDescentProfileRelation.updateProfile(profile);
+
+ if (!this.aircraftToDescentProfileRelation.isValid) {
+ this.changeState(DescentVerticalGuidanceState.InvalidProfile);
+ }
+ }
+
+ private changeState(newState: DescentVerticalGuidanceState) {
+ if (this.verticalState === newState) {
+ return;
+ }
+
+ if (this.verticalState !== DescentVerticalGuidanceState.InvalidProfile && newState === DescentVerticalGuidanceState.InvalidProfile) {
+ this.reset();
+ this.writeToSimVars();
+ }
+
+ this.verticalState = newState;
+ }
+
+ private reset() {
+ this.requestedVerticalMode = RequestedVerticalMode.None;
+ this.targetAltitude = 0;
+ this.targetVerticalSpeed = 0;
+ this.showLinearDeviationOnPfd = false;
+ this.showDescentLatchOnPfd = false;
+ this.isInOverspeedCondition = false;
+ }
+
+ update() {
+ this.aircraftToDescentProfileRelation.update();
+
+ if (!this.aircraftToDescentProfileRelation.isValid) {
+ return;
+ }
+
+ if ((this.observer.get().fcuVerticalMode === VerticalMode.DES) !== (this.verticalState === DescentVerticalGuidanceState.ProvidingGuidance)) {
+ this.changeState(this.verticalState === DescentVerticalGuidanceState.ProvidingGuidance ? DescentVerticalGuidanceState.Observing : DescentVerticalGuidanceState.ProvidingGuidance);
+ }
+ this.updateSpeedMarginState();
+
+ this.updateSpeedTarget();
+ this.updateSpeedGuidance();
+ this.updateOverUnderspeedCondition();
+ this.updateLinearDeviation();
+
+ if (this.verticalState === DescentVerticalGuidanceState.ProvidingGuidance) {
+ this.updateDesModeGuidance();
+ }
+
+ 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);
+ }
+ }
+
+ private updateLinearDeviation() {
+ this.targetAltitude = this.aircraftToDescentProfileRelation.currentTargetAltitude();
+
+ this.showLinearDeviationOnPfd = (this.observer.get().flightPhase >= FmgcFlightPhase.Descent || this.aircraftToDescentProfileRelation.isPastTopOfDescent())
+ && this.observer.get().fcuVerticalMode !== VerticalMode.GS_CPT
+ && this.observer.get().fcuVerticalMode !== VerticalMode.GS_TRACK
+ && this.observer.get().fcuVerticalMode !== VerticalMode.LAND
+ && this.observer.get().fcuVerticalMode !== VerticalMode.FLARE
+ && this.observer.get().fcuVerticalMode !== VerticalMode.ROLL_OUT;
+ }
+
+ private updateDesModeGuidance() {
+ const isOnGeometricPath = this.aircraftToDescentProfileRelation.isOnGeometricPath();
+ const isAboveSpeedLimitAltitude = this.aircraftToDescentProfileRelation.isAboveSpeedLimitAltitude();
+ const isBeforeTopOfDescent = !this.aircraftToDescentProfileRelation.isPastTopOfDescent();
+ const linearDeviation = this.aircraftToDescentProfileRelation.computeLinearDeviation();
+ const isSpeedAuto = Simplane.getAutoPilotAirspeedManaged();
+
+ this.targetAltitudeGuidance = this.atmosphericConditions.estimatePressureAltitudeInMsfs(
+ this.aircraftToDescentProfileRelation.currentTargetAltitude(),
+ );
+
+ if (linearDeviation > 200 || this.isInOverspeedCondition) {
+ // above path
+ this.requestedVerticalMode = RequestedVerticalMode.SpeedThrust;
+ } else if (isBeforeTopOfDescent || linearDeviation < -200) {
+ // below path
+ if (isOnGeometricPath) {
+ this.requestedVerticalMode = RequestedVerticalMode.FpaSpeed;
+ this.targetVerticalSpeed = this.aircraftToDescentProfileRelation.currentTargetPathAngle() / 2;
+ } else {
+ this.requestedVerticalMode = RequestedVerticalMode.VsSpeed;
+ this.targetVerticalSpeed = (isAboveSpeedLimitAltitude ? -1000 : -500);
+ }
+ } else if (!isOnGeometricPath && isSpeedAuto && !this.isInUnderspeedCondition) {
+ // on idle path
+
+ this.requestedVerticalMode = RequestedVerticalMode.VpathThrust;
+ this.targetVerticalSpeed = this.aircraftToDescentProfileRelation.currentTargetVerticalSpeed();
+ } else {
+ // on geometric path
+
+ this.requestedVerticalMode = RequestedVerticalMode.VpathSpeed;
+ this.targetVerticalSpeed = this.aircraftToDescentProfileRelation.currentTargetVerticalSpeed();
+ }
+ }
+
+ private updateSpeedTarget() {
+ const { fcuSpeed, managedDescentSpeedMach } = this.observer.get();
+ const inManagedSpeed = Simplane.getAutoPilotAirspeedManaged();
+
+ this.speedTarget = inManagedSpeed
+ ? Math.round(this.iasOrMach(this.aircraftToDescentProfileRelation.currentTargetSpeed(), managedDescentSpeedMach))
+ : fcuSpeed;
+ }
+
+ private writeToSimVars() {
+ SimVar.SetSimVarValue('L:A32NX_FG_REQUESTED_VERTICAL_MODE', 'Enum', this.requestedVerticalMode);
+ SimVar.SetSimVarValue('L:A32NX_FG_TARGET_ALTITUDE', 'Feet', this.targetAltitudeGuidance);
+ SimVar.SetSimVarValue('L:A32NX_FG_TARGET_VERTICAL_SPEED', 'number', this.targetVerticalSpeed);
+
+ SimVar.SetSimVarValue('L:A32NX_PFD_TARGET_ALTITUDE', 'Feet', this.targetAltitude);
+ SimVar.SetSimVarValue('L:A32NX_PFD_LINEAR_DEVIATION_ACTIVE', 'Bool', this.showLinearDeviationOnPfd);
+ SimVar.SetSimVarValue('L:A32NX_PFD_VERTICAL_PROFILE_LATCHED', 'Bool', this.showDescentLatchOnPfd);
+ }
+
+ private updateSpeedGuidance() {
+ if (this.speedState === DescentSpeedGuidanceState.NotInDescentPhase) {
+ return;
+ }
+
+ SimVar.SetSimVarValue('L:A32NX_SPEEDS_MANAGED_PFD', 'knots', this.speedTarget);
+
+ const maxBias = VnavConfig.DEBUG_PROFILE
+ ? SimVar.GetSimVarValue('L:A32NX_FM_VNAV_DEBUG_SPEED_BIAS', 'knots')
+ : 3;
+
+ const speedBias = this.requestedVerticalMode === RequestedVerticalMode.SpeedThrust
+ ? Math.max(Math.min(this.aircraftToDescentProfileRelation.computeLinearDeviation(), maxBias), 0)
+ : 0;
+
+ const airspeed = SimVar.GetSimVarValue('AIRSPEED INDICATED', 'knots');
+ const guidanceTarget = this.speedState === DescentSpeedGuidanceState.TargetAndMargins
+ ? this.speedMargin.getTarget(airspeed + speedBias, this.speedTarget)
+ : this.speedTarget;
+ SimVar.SetSimVarValue('L:A32NX_SPEEDS_MANAGED_ATHR', 'knots', guidanceTarget);
+
+ if (this.speedState === DescentSpeedGuidanceState.TargetAndMargins) {
+ const [lower, upper] = this.speedMargin.getMargins(this.speedTarget);
+
+ SimVar.SetSimVarValue('L:A32NX_PFD_LOWER_SPEED_MARGIN', 'Knots', lower);
+ SimVar.SetSimVarValue('L:A32NX_PFD_UPPER_SPEED_MARGIN', 'Knots', upper);
+ }
+ }
+
+ private updateSpeedMarginState() {
+ const { flightPhase } = this.observer.get();
+
+ if (flightPhase !== FmgcFlightPhase.Descent) {
+ this.changeSpeedState(DescentSpeedGuidanceState.NotInDescentPhase);
+ return;
+ }
+
+ const shouldShowMargins = this.verticalState === DescentVerticalGuidanceState.ProvidingGuidance && Simplane.getAutoPilotAirspeedManaged();
+ this.changeSpeedState(shouldShowMargins ? DescentSpeedGuidanceState.TargetAndMargins : DescentSpeedGuidanceState.TargetOnly);
+ }
+
+ private changeSpeedState(newState: DescentSpeedGuidanceState) {
+ if (this.speedState === newState) {
+ return;
+ }
+
+ // Hide margins if they were previously visible, but the state changed to literally anything else
+ if (this.speedState === DescentSpeedGuidanceState.TargetAndMargins) {
+ SimVar.SetSimVarValue('L:A32NX_PFD_SHOW_SPEED_MARGINS', 'boolean', false);
+ SimVar.SetSimVarValue('L:A32NX_PFD_LOWER_SPEED_MARGIN', 'Knots', 0);
+ SimVar.SetSimVarValue('L:A32NX_PFD_UPPER_SPEED_MARGIN', 'Knots', 0);
+ } else if (newState === DescentSpeedGuidanceState.TargetAndMargins) {
+ SimVar.SetSimVarValue('L:A32NX_PFD_SHOW_SPEED_MARGINS', 'boolean', true);
+ }
+
+ this.speedState = newState;
+ }
+
+ private iasOrMach(ias: Knots, mach: Mach) {
+ const machAsIas = SimVar.GetGameVarValue('FROM MACH TO KIAS', 'number', mach);
+
+ if (ias > machAsIas) {
+ return machAsIas;
+ }
+
+ return ias;
+ }
+
+ private updateOverUnderspeedCondition() {
+ const airspeed = this.atmosphericConditions.currentAirspeed;
+
+ let upperLimit = this.speedTarget;
+ let lowerLimit = this.speedTarget;
+ if (this.speedState === DescentSpeedGuidanceState.TargetAndMargins) {
+ const [lower, upper] = this.speedMargin.getMargins(this.speedTarget);
+
+ lowerLimit = lower;
+ upperLimit = upper;
+ }
+
+ if (this.isInOverspeedCondition && airspeed < upperLimit) {
+ this.isInOverspeedCondition = false;
+ } else if (!this.isInOverspeedCondition && airspeed > upperLimit + 3) {
+ this.isInOverspeedCondition = true;
+ }
+
+ if (this.isInUnderspeedCondition && airspeed > lowerLimit) {
+ this.isInUnderspeedCondition = false;
+ } else if (!this.isInUnderspeedCondition && airspeed < lowerLimit - 3) {
+ this.isInUnderspeedCondition = true;
+ }
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/descent/DescentPathBuilder.ts b/src/fmgc/src/guidance/vnav/descent/DescentPathBuilder.ts
new file mode 100644
index 000000000000..4638d737bc22
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/descent/DescentPathBuilder.ts
@@ -0,0 +1,148 @@
+import { VerticalCheckpointReason } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { BaseGeometryProfile } from '@fmgc/guidance/vnav/profile/BaseGeometryProfile';
+import { ManagedSpeedType, SpeedProfile } from '@fmgc/guidance/vnav/climb/SpeedProfile';
+import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { GeometricPathBuilder } from '@fmgc/guidance/vnav/descent/GeometricPathBuilder';
+import { DescentStrategy, IdleDescentStrategy } from '@fmgc/guidance/vnav/descent/DescentStrategy';
+import { StepResults } from '@fmgc/guidance/vnav/Predictions';
+import { HeadwindProfile } from '@fmgc/guidance/vnav/wind/HeadwindProfile';
+import { TemporaryCheckpointSequence } from '@fmgc/guidance/vnav/profile/TemporaryCheckpointSequence';
+
+export class DescentPathBuilder {
+ private geometricPathBuilder: GeometricPathBuilder;
+
+ private idleDescentStrategy: DescentStrategy;
+
+ constructor(
+ private computationParametersObserver: VerticalProfileComputationParametersObserver,
+ private atmosphericConditions: AtmosphericConditions,
+ ) {
+ this.geometricPathBuilder = new GeometricPathBuilder(
+ computationParametersObserver,
+ atmosphericConditions,
+ );
+
+ this.idleDescentStrategy = new IdleDescentStrategy(computationParametersObserver, atmosphericConditions);
+ }
+
+ update() {
+ this.atmosphericConditions.update();
+ }
+
+ computeManagedDescentPath(
+ sequence: TemporaryCheckpointSequence,
+ profile: BaseGeometryProfile,
+ speedProfile: SpeedProfile,
+ windProfile: HeadwindProfile,
+ cruiseAltitude: Feet,
+ ) {
+ this.geometricPathBuilder.buildGeometricPath(sequence, profile, speedProfile, windProfile, cruiseAltitude);
+
+ if (sequence.lastCheckpoint.reason !== VerticalCheckpointReason.GeometricPathStart) {
+ console.error('[FMS/VNAV] Geometric path did not end in GeometricPathStart. Discarding descent profile.');
+ return;
+ }
+
+ this.buildIdlePath(sequence, profile, speedProfile, windProfile, cruiseAltitude);
+ }
+
+ private buildIdlePath(sequence: TemporaryCheckpointSequence, profile: BaseGeometryProfile, speedProfile: SpeedProfile, windProfile: HeadwindProfile, topOfDescentAltitude: Feet): void {
+ // Assume the last checkpoint is the start of the geometric path
+ sequence.copyLastCheckpoint({ reason: VerticalCheckpointReason.IdlePathEnd });
+
+ const { managedDescentSpeedMach } = this.computationParametersObserver.get();
+
+ const speedConstraints = profile.descentSpeedConstraints.slice().sort((a, b) => b.distanceFromStart - a.distanceFromStart);
+ let i = 0;
+ while (i++ < 50 && speedConstraints.length > 0) {
+ const constraint = speedConstraints[0];
+ const { distanceFromStart, remainingFuelOnBoard, speed, altitude } = sequence.lastCheckpoint;
+
+ if (constraint.distanceFromStart >= distanceFromStart) {
+ speedConstraints.splice(0, 1);
+ continue;
+ }
+
+ const speedTargetBeforeCurrentPosition = speedProfile.getTarget(constraint.distanceFromStart, altitude, ManagedSpeedType.Descent);
+ // It is safe to use the current altitude here. This way, the speed limit will certainly be obeyed
+ if (speedTargetBeforeCurrentPosition - speed > 1) {
+ const headwind = windProfile.getHeadwindComponent(distanceFromStart, altitude);
+
+ const decelerationStep = this.idleDescentStrategy.predictToSpeedBackwards(
+ altitude,
+ speed,
+ speedTargetBeforeCurrentPosition,
+ managedDescentSpeedMach,
+ remainingFuelOnBoard,
+ headwind,
+ );
+
+ sequence.addCheckpointFromStepBackwards(decelerationStep, VerticalCheckpointReason.IdlePathAtmosphericConditions);
+
+ continue;
+ }
+
+ const headwind = windProfile.getHeadwindComponent(distanceFromStart, altitude);
+ const descentStep = this.idleDescentStrategy.predictToDistanceBackwards(
+ altitude,
+ sequence.lastCheckpoint.distanceFromStart - constraint.distanceFromStart,
+ speed,
+ managedDescentSpeedMach,
+ remainingFuelOnBoard,
+ headwind,
+ );
+
+ sequence.addCheckpointFromStepBackwards(descentStep, VerticalCheckpointReason.IdlePathAtmosphericConditions);
+ }
+
+ let j = 0;
+ for (let altitude = sequence.lastCheckpoint.altitude; altitude < topOfDescentAltitude && j++ < 50; altitude = Math.min(altitude + 1500, topOfDescentAltitude)) {
+ const { distanceFromStart, remainingFuelOnBoard, speed } = sequence.lastCheckpoint;
+
+ const startingAltitudeForSegment = Math.min(altitude + 1500, topOfDescentAltitude);
+ // Get target slightly before to figure out if we want to accelerate
+ const speedTarget = speedProfile.getTarget(distanceFromStart - 1e-4, altitude, ManagedSpeedType.Descent);
+
+ if ((speedTarget - speed) > 1) {
+ const headwind = windProfile.getHeadwindComponent(distanceFromStart, altitude);
+ const decelerationStep = this.idleDescentStrategy.predictToSpeedBackwards(altitude, speed, speedTarget, managedDescentSpeedMach, remainingFuelOnBoard, headwind);
+
+ // If we shoot through the final altitude trying to accelerate, pretend we didn't accelerate all the way
+ if (decelerationStep.initialAltitude > topOfDescentAltitude) {
+ const scaling = (decelerationStep.initialAltitude - decelerationStep.finalAltitude) === 0
+ ? (topOfDescentAltitude - decelerationStep.finalAltitude) / (decelerationStep.initialAltitude - decelerationStep.finalAltitude)
+ : 0;
+
+ this.scaleStep(decelerationStep, scaling);
+ }
+
+ sequence.addCheckpointFromStepBackwards(decelerationStep, VerticalCheckpointReason.IdlePathAtmosphericConditions);
+
+ // Stupid hack
+ altitude = sequence.lastCheckpoint.altitude - 1500;
+ continue;
+ }
+
+ const headwind = windProfile.getHeadwindComponent(sequence.lastCheckpoint.distanceFromStart, sequence.lastCheckpoint.altitude);
+
+ const step = this.idleDescentStrategy.predictToAltitude(startingAltitudeForSegment, altitude, speed, managedDescentSpeedMach, remainingFuelOnBoard, headwind);
+ sequence.addCheckpointFromStepBackwards(step, VerticalCheckpointReason.IdlePathAtmosphericConditions);
+ }
+
+ if (sequence.lastCheckpoint.reason === VerticalCheckpointReason.IdlePathAtmosphericConditions) {
+ sequence.lastCheckpoint.reason = VerticalCheckpointReason.TopOfDescent;
+ } else {
+ sequence.copyLastCheckpoint(({ reason: VerticalCheckpointReason.TopOfDescent }));
+ }
+ }
+
+ // TODO: Rethink the existence of thsi
+ private scaleStep(step: StepResults, scaling: number) {
+ step.distanceTraveled *= scaling;
+ step.fuelBurned *= scaling;
+ step.timeElapsed *= scaling;
+ step.finalAltitude *= scaling;
+ step.speed *= scaling;
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/descent/DescentStrategy.ts b/src/fmgc/src/guidance/vnav/descent/DescentStrategy.ts
new file mode 100644
index 000000000000..cc811d67b84c
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/descent/DescentStrategy.ts
@@ -0,0 +1,192 @@
+import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions';
+import { FlapConf } from '@fmgc/guidance/vnav/common';
+import { AircraftConfiguration } from '@fmgc/guidance/vnav/descent/ApproachPathBuilder';
+import { EngineModel } from '@fmgc/guidance/vnav/EngineModel';
+import { Predictions, StepResults } from '@fmgc/guidance/vnav/Predictions';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { VnavConfig } from '@fmgc/guidance/vnav/VnavConfig';
+import { WindComponent } from '@fmgc/guidance/vnav/wind';
+
+const DEFAULT_CONFIG: AircraftConfiguration = {
+ flapConfig: FlapConf.CLEAN,
+ speedbrakesExtended: false,
+ gearExtended: false,
+};
+
+export interface DescentStrategy {
+ /**
+ * Computes predictions for a single segment using the atmospheric conditions in the middle.
+ * @param initialAltitude Altitude at the start of descent
+ * @param finalAltitude Altitude to terminate the descent
+ * @param speed
+ * @param mach
+ * @param fuelOnBoard Remainging fuel on board at the start of the descent
+ * @returns `StepResults`
+ */
+ predictToAltitude(
+ initialAltitude: number, finalAltitude: number, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent, config?: AircraftConfiguration
+ ): StepResults;
+
+ /**
+ * Computes a descent step forwards
+ * @param initialAltitude Altitude that you should end up at after descending
+ * @param distance
+ * @param speed
+ * @param mach
+ * @param fuelOnBoard
+ */
+ predictToDistance(
+ initialAltitude: number, distance: NauticalMiles, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent, config?: AircraftConfiguration
+ ): StepResults;
+
+ /**
+ * Computes a descent step backwards
+ * @param finalAltitude Altitude that you should end up at after descending
+ * @param distance
+ * @param speed
+ * @param mach
+ * @param fuelOnBoard
+ */
+ predictToDistanceBackwards(
+ finalAltitude: number, distance: NauticalMiles, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent, config?: AircraftConfiguration
+ ): StepResults;
+
+ /**
+ * Computes a step from an initial altitude until the aircraft reaches finalSpeed
+ * @param initialAltitude
+ * @param speed
+ * @param finalSpeed
+ * @param mach
+ * @param fuelOnBoard
+ */
+ predictToSpeed(
+ initialAltitude: number, speed: Knots, finalSpeed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent, config?: AircraftConfiguration
+ ): StepResults
+
+ /**
+ * Computes a descending deceleration backwards
+ * @param finalAltitude Altitude that you should end up at after descending
+ * @param finalSpeed Speed that you should be at after decelerating
+ * @param speed
+ * @param mach
+ * @param fuelOnBoard
+ */
+ predictToSpeedBackwards(
+ finalAltitude: number, finalSpeed: Knots, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent, config?: AircraftConfiguration
+ ): StepResults;
+}
+
+export class IdleDescentStrategy implements DescentStrategy {
+ constructor(private observer: VerticalProfileComputationParametersObserver,
+ private atmosphericConditions: AtmosphericConditions,
+ private defaultConfig: AircraftConfiguration = DEFAULT_CONFIG) { }
+
+ predictToAltitude(
+ initialAltitude: number, finalAltitude: number, speed: number, mach: number, fuelOnBoard: number, headwindComponent: WindComponent, config: AircraftConfiguration = this.defaultConfig,
+ ): StepResults {
+ const { zeroFuelWeight, perfFactor, tropoPause } = this.observer.get();
+
+ const midwayAltitude = (initialAltitude + finalAltitude) / 2;
+ const computedMach = Math.min(this.atmosphericConditions.computeMachFromCas(midwayAltitude, speed), mach);
+ const predictedN1 = EngineModel.getIdleN1(midwayAltitude, computedMach) + VnavConfig.IDLE_N1_MARGIN;
+
+ return Predictions.altitudeStep(
+ initialAltitude,
+ finalAltitude - initialAltitude,
+ speed,
+ mach,
+ predictedN1,
+ zeroFuelWeight,
+ fuelOnBoard,
+ headwindComponent.value,
+ this.atmosphericConditions.isaDeviation,
+ tropoPause,
+ config.speedbrakesExtended,
+ config.flapConfig,
+ perfFactor,
+ );
+ }
+
+ predictToDistance(
+ initialAltitude: number, distance: number, speed: number, mach: number, fuelOnBoard: number, headwindComponent: WindComponent, config: AircraftConfiguration = this.defaultConfig,
+ ): StepResults {
+ const { zeroFuelWeight, perfFactor, tropoPause } = this.observer.get();
+
+ const computedMach = Math.min(this.atmosphericConditions.computeMachFromCas(initialAltitude, speed), mach);
+ const predictedN1 = EngineModel.getIdleN1(initialAltitude, computedMach) + VnavConfig.IDLE_N1_MARGIN;
+
+ return Predictions.distanceStep(
+ initialAltitude,
+ distance,
+ speed,
+ mach,
+ predictedN1,
+ zeroFuelWeight,
+ fuelOnBoard,
+ headwindComponent.value,
+ this.atmosphericConditions.isaDeviation,
+ tropoPause,
+ config.speedbrakesExtended,
+ config.flapConfig,
+ perfFactor,
+ );
+ }
+
+ predictToDistanceBackwards(
+ finalAltitude: number, distance: number, speed: number, mach: number, fuelOnBoard: number, headwindComponent: WindComponent, config: AircraftConfiguration = this.defaultConfig,
+ ): StepResults {
+ const { zeroFuelWeight, perfFactor, tropoPause } = this.observer.get();
+
+ const computedMach = Math.min(this.atmosphericConditions.computeMachFromCas(finalAltitude, speed), mach);
+ const predictedN1 = EngineModel.getIdleN1(finalAltitude, computedMach) + VnavConfig.IDLE_N1_MARGIN;
+
+ return Predictions.reverseDistanceStep(
+ finalAltitude,
+ distance,
+ speed,
+ mach,
+ predictedN1,
+ zeroFuelWeight,
+ fuelOnBoard,
+ headwindComponent.value,
+ this.atmosphericConditions.isaDeviation,
+ tropoPause,
+ config.speedbrakesExtended,
+ config.flapConfig,
+ perfFactor,
+ );
+ }
+
+ predictToSpeed(
+ _initialAltitude: number, _speed: Knots, _finalSpeed: Knots, _mach: Mach, _fuelOnBoard: number, _headwindComponent: WindComponent, _config: AircraftConfiguration = this.defaultConfig,
+ ): StepResults {
+ throw new Error('[FMS/VNAV] predictToSpeed not implemented for IdleDescentStrategy');
+ }
+
+ predictToSpeedBackwards(
+ finalAltitude: number, finalSpeed: Knots, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent, config: AircraftConfiguration = this.defaultConfig,
+ ): StepResults {
+ const { zeroFuelWeight, perfFactor, tropoPause } = this.observer.get();
+
+ const initialMach = Math.min(this.atmosphericConditions.computeMachFromCas(finalAltitude, speed), mach);
+ const finalMach = Math.min(this.atmosphericConditions.computeMachFromCas(finalAltitude, finalSpeed), mach);
+
+ const predictedN1 = EngineModel.getIdleN1(finalAltitude, (initialMach + finalMach) / 2) + VnavConfig.IDLE_N1_MARGIN;
+
+ return Predictions.reverseAltitudeStepWithSpeedChange(
+ finalAltitude,
+ speed,
+ finalSpeed,
+ mach,
+ predictedN1,
+ zeroFuelWeight,
+ fuelOnBoard,
+ headwindComponent.value,
+ this.atmosphericConditions.isaDeviation,
+ tropoPause,
+ config.speedbrakesExtended,
+ config.flapConfig,
+ perfFactor,
+ );
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/descent/GeometricPathBuilder.ts b/src/fmgc/src/guidance/vnav/descent/GeometricPathBuilder.ts
new file mode 100644
index 000000000000..6d1f2681a922
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/descent/GeometricPathBuilder.ts
@@ -0,0 +1,365 @@
+import { AltitudeConstraintType } from '@fmgc/guidance/lnav/legs';
+import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions';
+import { ManagedSpeedType, SpeedProfile } from '@fmgc/guidance/vnav/climb/SpeedProfile';
+import { FlapConf } from '@fmgc/guidance/vnav/common';
+import { EngineModel } from '@fmgc/guidance/vnav/EngineModel';
+import { Predictions, StepResults, VnavStepError } from '@fmgc/guidance/vnav/Predictions';
+import { BaseGeometryProfile } from '@fmgc/guidance/vnav/profile/BaseGeometryProfile';
+import { DescentAltitudeConstraint, VerticalCheckpoint, VerticalCheckpointReason } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { TemporaryCheckpointSequence } from '@fmgc/guidance/vnav/profile/TemporaryCheckpointSequence';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { VnavConfig } from '@fmgc/guidance/vnav/VnavConfig';
+import { HeadwindProfile } from '@fmgc/guidance/vnav/wind/HeadwindProfile';
+
+export class GeometricPathBuilder {
+ constructor(
+ private observer: VerticalProfileComputationParametersObserver,
+ private atmosphericConditions: AtmosphericConditions,
+ ) { }
+
+ buildGeometricPath(sequence: TemporaryCheckpointSequence, profile: BaseGeometryProfile, speedProfile: SpeedProfile, windProfile: HeadwindProfile, finalCruiseAltitude: Feet) {
+ const constraintsToUse = profile.descentAltitudeConstraints
+ .slice()
+ .filter(
+ (constraint) => this.isConstraintBelowCruisingAltitude(constraint, finalCruiseAltitude)
+ && constraint.distanceFromStart < sequence.lastCheckpoint.distanceFromStart,
+ );
+
+ constraintsToUse.sort((a, b) => b.distanceFromStart - a.distanceFromStart);
+
+ const planner = new GeometricPathPlanner(
+ this.observer,
+ this.atmosphericConditions,
+ windProfile,
+ speedProfile,
+ constraintsToUse,
+ sequence.lastCheckpoint,
+ finalCruiseAltitude,
+ );
+
+ for (let i = 0; i < 100 && planner.currentConstraintIndex < constraintsToUse.length; i++) {
+ planner.stepAlong();
+ }
+
+ planner.finalize(sequence);
+ }
+
+ isConstraintBelowCruisingAltitude(constraint: DescentAltitudeConstraint, finalCruiseAltitude: Feet): boolean {
+ if (constraint.constraint.type === AltitudeConstraintType.at) {
+ return constraint.constraint.altitude1 <= finalCruiseAltitude;
+ } if (constraint.constraint.type === AltitudeConstraintType.atOrAbove) {
+ return constraint.constraint.altitude1 <= finalCruiseAltitude;
+ } if (constraint.constraint.type === AltitudeConstraintType.atOrBelow) {
+ return true;
+ } if (constraint.constraint.type === AltitudeConstraintType.range) {
+ return constraint.constraint.altitude2 <= finalCruiseAltitude;
+ }
+
+ return true;
+ }
+}
+
+class GeometricPathPlanner {
+ public currentConstraintIndex: number = 0;
+
+ // Positive means the altitude could be moved higher
+ private altitudeAvailableByConstraint: Feet[][];
+
+ private steps: StepResults[];
+
+ public currentCheckpoint: VerticalCheckpoint = null;
+
+ /**
+ * Defines the maximum altitude to be at each constraint such that it is possible to meet the other constraints
+ * E.g There's an atOrAbove constraint (10000+) 50 NM along track, and an atOrBelow constraint (12000-) 40 NM along track,
+ * we don't want to be above 12000 at the 10000+ constraint. Otherwise, we'll have to climb in the descent.
+ */
+ private maxAltitudeConstraints: Feet[];
+
+ private speedBrakeRequests: boolean[];
+
+ constructor(
+ private observer: VerticalProfileComputationParametersObserver,
+ private atmosphericConditions: AtmosphericConditions,
+ private windProfile: HeadwindProfile,
+ private speedProfile: SpeedProfile,
+ private constraints: DescentAltitudeConstraint[],
+ private startingPoint: VerticalCheckpoint,
+ private finalCruiseAltitude: Feet,
+ ) {
+ this.currentCheckpoint = { ...startingPoint };
+
+ this.altitudeAvailableByConstraint = new Array(this.constraints.length);
+ this.steps = new Array(this.constraints.length);
+ this.speedBrakeRequests = new Array(this.constraints.length);
+
+ this.maxAltitudeConstraints = this.findCumulativeMaxAltitudes(this.constraints.slice().reverse());
+ // TODO: Sort this while computing
+ this.maxAltitudeConstraints.reverse();
+ }
+
+ stepAlong() {
+ const constraintAlongTrack = this.constraints[this.currentConstraintIndex];
+
+ if (constraintAlongTrack.distanceFromStart > this.currentCheckpoint.distanceFromStart) {
+ throw new Error('[FMS/VNAV] Constraint does not lie in descent path');
+ }
+
+ if (constraintAlongTrack.constraint.type === AltitudeConstraintType.at) {
+ this.stepToAtConstraint();
+ } else if (constraintAlongTrack.constraint.type === AltitudeConstraintType.atOrAbove) {
+ if (!this.speedBrakeRequests[this.currentConstraintIndex]) {
+ this.speedBrakeRequests[this.currentConstraintIndex] = false;
+ }
+
+ this.prepareIdleStep();
+
+ const endsUpTooHighForUpcomingConstraint = this.currentCheckpoint.altitude > this.maxAltitudeConstraints[this.currentConstraintIndex - 1];
+ if (endsUpTooHighForUpcomingConstraint) {
+ this.resetToIndex(this.currentConstraintIndex - 1);
+ this.prepareGeometricStep(this.maxAltitudeConstraints[this.currentConstraintIndex]);
+
+ return;
+ }
+
+ const altError = this.currentCheckpoint.altitude - constraintAlongTrack.constraint.altitude1;
+ // If constraint is met
+ if (altError > 0) {
+ return;
+ }
+
+ // If this doesn't work, try speed brakes
+ if (!this.speedBrakeRequests[this.currentConstraintIndex - 1]) {
+ this.speedBrakeRequests[this.currentConstraintIndex - 1] = true;
+ this.resetToIndex(this.currentConstraintIndex - 1);
+ } else {
+ // Insert path too steep at this point
+ this.steps[this.currentConstraintIndex - 1].error = VnavStepError.AVAILABLE_GRADIENT_INSUFFICIENT;
+ }
+ } else if (constraintAlongTrack.constraint.type === AltitudeConstraintType.atOrBelow) {
+ this.prepareIdleStep();
+
+ const maxAltitude = Math.min(this.maxAltitudeConstraints[this.currentConstraintIndex - 1], constraintAlongTrack.constraint.altitude1);
+ const endsUpTooHigh = this.currentCheckpoint.altitude > maxAltitude;
+ if (endsUpTooHigh) {
+ this.resetToIndex(this.currentConstraintIndex - 1);
+ this.prepareGeometricStep(maxAltitude);
+ }
+ } else if (constraintAlongTrack.constraint.type === AltitudeConstraintType.range) {
+ this.prepareIdleStep();
+
+ const maxAltitude = Math.min(this.maxAltitudeConstraints[this.currentConstraintIndex - 1], constraintAlongTrack.constraint.altitude1);
+
+ const endsUpTooHigh = this.currentCheckpoint.altitude > maxAltitude;
+ const endsUpTooLow = this.currentCheckpoint.altitude < constraintAlongTrack.constraint.altitude2;
+
+ if (endsUpTooHigh) {
+ this.resetToIndex(this.currentConstraintIndex - 1);
+ this.prepareGeometricStep(maxAltitude);
+ } else if (endsUpTooLow) {
+ // If this doesn't work, try speed brakes
+ if (!this.speedBrakeRequests[this.currentConstraintIndex - 1]) {
+ this.speedBrakeRequests[this.currentConstraintIndex - 1] = true;
+ this.resetToIndex(this.currentConstraintIndex - 1);
+ } else {
+ // Insert path too steep at this point
+ this.steps[this.currentConstraintIndex - 1].error = VnavStepError.AVAILABLE_GRADIENT_INSUFFICIENT;
+ }
+ }
+ } else {
+ throw new Error('[FMS/VNAV] Unknown constraint type');
+ }
+ }
+
+ stepToAtConstraint() {
+ const { managedDescentSpeedMach, zeroFuelWeight, tropoPause } = this.observer.get();
+ const { altitude, distanceFromStart, remainingFuelOnBoard } = this.currentCheckpoint;
+ const constraint = this.constraints[this.currentConstraintIndex];
+
+ const stepSpeed = this.speedProfile.getTarget(constraint.distanceFromStart, altitude, ManagedSpeedType.Descent);
+
+ const headwind = this.windProfile.getHeadwindComponent(distanceFromStart, altitude);
+
+ const step = Predictions.geometricStep(
+ constraint.constraint.altitude1,
+ altitude,
+ distanceFromStart - constraint.distanceFromStart,
+ stepSpeed,
+ managedDescentSpeedMach,
+ zeroFuelWeight,
+ remainingFuelOnBoard, // TODO: Predict fuel at start of descent, not at the end
+ this.atmosphericConditions.isaDeviation,
+ headwind.value,
+ tropoPause,
+ false,
+ FlapConf.CLEAN,
+ );
+
+ this.steps[this.currentConstraintIndex++] = step;
+ this.currentCheckpoint = this.updateCheckpointFromStep(this.currentCheckpoint, step);
+ }
+
+ resetToIndex(index: number) {
+ this.steps.splice(index);
+ this.altitudeAvailableByConstraint.splice(index);
+ this.currentConstraintIndex = index;
+
+ this.currentCheckpoint = this.startingPoint;
+ for (let i = 0; i < index; i++) {
+ this.currentCheckpoint = this.updateCheckpointFromStep(this.currentCheckpoint, this.steps[i]);
+ }
+ }
+
+ findLowerAvailableAltitude(requestedAltitude: Feet): number {
+ let totalAvailableAltitude = 0;
+
+ for (let i = this.currentConstraintIndex - 1; i > 0; i--) {
+ for (let j = 0; j < this.altitudeAvailableByConstraint[i].length; j++) {
+ totalAvailableAltitude += Math.min(this.altitudeAvailableByConstraint[i][j], 0);
+
+ if (totalAvailableAltitude < requestedAltitude) {
+ return i;
+ }
+ }
+ }
+
+ return -1;
+ }
+
+ findHigherAvailableAltitude(requestedAltitude: Feet): number {
+ let totalAvailableAltitude = 0;
+
+ for (let i = this.currentConstraintIndex - 1; i > 0; i--) {
+ for (let j = 0; j < this.altitudeAvailableByConstraint[i].length; j++) {
+ totalAvailableAltitude += Math.max(this.altitudeAvailableByConstraint[i][j], 0);
+
+ if (totalAvailableAltitude > requestedAltitude) {
+ return i;
+ }
+ }
+ }
+
+ return -1;
+ }
+
+ prepareIdleStep() {
+ const { managedDescentSpeedMach, zeroFuelWeight, tropoPause } = this.observer.get();
+ const { altitude, distanceFromStart, remainingFuelOnBoard } = this.currentCheckpoint;
+ const constraint = this.constraints[this.currentConstraintIndex];
+
+ const stepSpeed = this.speedProfile.getTarget(constraint.distanceFromStart, altitude, ManagedSpeedType.Descent);
+ const stepSpeedMach = this.atmosphericConditions.computeMachFromCas(altitude, stepSpeed);
+
+ const headwind = this.windProfile.getHeadwindComponent(distanceFromStart, altitude);
+
+ const step = Predictions.reverseDistanceStep(
+ altitude,
+ distanceFromStart - constraint.distanceFromStart,
+ stepSpeed,
+ managedDescentSpeedMach,
+ EngineModel.getIdleN1(altitude, stepSpeedMach) + VnavConfig.IDLE_N1_MARGIN,
+ zeroFuelWeight,
+ remainingFuelOnBoard, // TODO: Predict fuel at start of descent, not at the end
+ headwind.value,
+ this.atmosphericConditions.isaDeviation,
+ tropoPause,
+ this.speedBrakeRequests[this.currentConstraintIndex],
+ );
+
+ this.steps[this.currentConstraintIndex] = step;
+
+ if (!this.altitudeAvailableByConstraint[this.currentConstraintIndex]) {
+ this.altitudeAvailableByConstraint[this.currentConstraintIndex] = [];
+ }
+
+ if (constraint.constraint.type === AltitudeConstraintType.atOrBelow) {
+ this.altitudeAvailableByConstraint[this.currentConstraintIndex++].push(Math.max(constraint.constraint.altitude1 - step.initialAltitude, 0));
+ } else if (constraint.constraint.type === AltitudeConstraintType.atOrAbove) {
+ this.altitudeAvailableByConstraint[this.currentConstraintIndex++].push(Math.min(constraint.constraint.altitude1 - step.initialAltitude, 0));
+ } else if (constraint.constraint.type === AltitudeConstraintType.range) {
+ this.altitudeAvailableByConstraint[this.currentConstraintIndex].push(Math.max(constraint.constraint.altitude1 - step.initialAltitude, 0));
+ this.altitudeAvailableByConstraint[this.currentConstraintIndex++].push(Math.min(constraint.constraint.altitude2 - step.initialAltitude, 0));
+ }
+
+ this.currentCheckpoint = this.updateCheckpointFromStep(this.currentCheckpoint, step);
+ }
+
+ prepareGeometricStep(alitudeToStartDescentFrom: Feet) {
+ const { managedDescentSpeedMach, zeroFuelWeight, tropoPause } = this.observer.get();
+ const { altitude, remainingFuelOnBoard, distanceFromStart } = this.currentCheckpoint;
+ const constraint = this.constraints[this.currentConstraintIndex];
+
+ const stepSpeed = this.speedProfile.getTarget(constraint.distanceFromStart, altitude, ManagedSpeedType.Descent);
+
+ const headwind = this.windProfile.getHeadwindComponent(distanceFromStart, altitude);
+
+ const step = Predictions.geometricStep(
+ alitudeToStartDescentFrom,
+ altitude,
+ distanceFromStart - constraint.distanceFromStart,
+ stepSpeed,
+ managedDescentSpeedMach,
+ zeroFuelWeight,
+ remainingFuelOnBoard, // TODO: Predict fuel at start of descent, not at the end
+ this.atmosphericConditions.isaDeviation,
+ headwind.value,
+ tropoPause,
+ false,
+ FlapConf.CLEAN,
+ );
+
+ this.steps[this.currentConstraintIndex] = step;
+
+ if (!this.altitudeAvailableByConstraint[this.currentConstraintIndex]) {
+ this.altitudeAvailableByConstraint[this.currentConstraintIndex] = [];
+ }
+
+ if (constraint.constraint.type === AltitudeConstraintType.atOrBelow) {
+ this.altitudeAvailableByConstraint[this.currentConstraintIndex++].push(Math.max(constraint.constraint.altitude1 - step.initialAltitude, 0));
+ } else if (constraint.constraint.type === AltitudeConstraintType.atOrAbove) {
+ this.altitudeAvailableByConstraint[this.currentConstraintIndex++].push(Math.min(constraint.constraint.altitude1 - step.initialAltitude, 0));
+ } else if (constraint.constraint.type === AltitudeConstraintType.range) {
+ this.altitudeAvailableByConstraint[this.currentConstraintIndex].push(Math.max(constraint.constraint.altitude1 - step.initialAltitude, 0));
+ this.altitudeAvailableByConstraint[this.currentConstraintIndex++].push(Math.min(constraint.constraint.altitude2 - step.initialAltitude, 0));
+ }
+
+ this.currentCheckpoint = this.updateCheckpointFromStep(this.currentCheckpoint, step);
+ }
+
+ finalize(sequence: TemporaryCheckpointSequence) {
+ sequence.copyLastCheckpoint({ reason: VerticalCheckpointReason.GeometricPathEnd });
+ this.steps.forEach((step) => sequence.addCheckpointFromStepBackwards(step, VerticalCheckpointReason.GeometricPathConstraint));
+ sequence.copyLastCheckpoint({ reason: VerticalCheckpointReason.GeometricPathStart });
+ }
+
+ private updateCheckpointFromStep(checkpoint: VerticalCheckpoint, step: StepResults): VerticalCheckpoint {
+ return {
+ reason: VerticalCheckpointReason.GeometricPathConstraint,
+ altitude: step.initialAltitude,
+ distanceFromStart: checkpoint.distanceFromStart - step.distanceTraveled,
+ remainingFuelOnBoard: checkpoint.remainingFuelOnBoard + step.fuelBurned,
+ secondsFromPresent: checkpoint.secondsFromPresent - step.timeElapsed,
+ speed: step.speed,
+ mach: checkpoint.mach,
+ };
+ }
+
+ /**
+ *
+ * @param constraints - This should be in the order they appear in the flight plan
+ */
+ findCumulativeMaxAltitudes(constraints: DescentAltitudeConstraint[]): Feet[] {
+ const cumulativeMaxAltitudes = new Array(constraints.length);
+ let maxAltitude = this.finalCruiseAltitude;
+
+ for (const constraint of constraints) {
+ if (constraint.constraint.type !== AltitudeConstraintType.atOrAbove) {
+ maxAltitude = Math.min(maxAltitude, constraint.constraint.altitude1);
+ }
+
+ cumulativeMaxAltitudes.push(maxAltitude);
+ }
+
+ return cumulativeMaxAltitudes;
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/descent/GeomtricPathBuilder.ts b/src/fmgc/src/guidance/vnav/descent/GeomtricPathBuilder.ts
deleted file mode 100644
index 77c1e427f3c6..000000000000
--- a/src/fmgc/src/guidance/vnav/descent/GeomtricPathBuilder.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { Geometry } from '@fmgc/guidance/Geometry';
-import { GeometricPath } from '@fmgc/guidance/vnav/descent/TheoreticalDescentPath';
-import { Predictions, StepResults } from '@fmgc/guidance/vnav/Predictions';
-import { Leg } from '@fmgc/guidance/lnav/legs/Leg';
-
-export class GeomtricPathBuilder {
- static buildGeometricPath(
- geometry: Geometry,
- endLegIdx: number,
- ): GeometricPath {
- const fpaTable = {};
-
- let currentLegIdx = endLegIdx;
- let currentLeg = geometry.legs.get(currentLegIdx);
- let [previousLeg] = GeomtricPathBuilder.findSlopeStart(geometry, currentLegIdx);
-
- while (previousLeg) {
- const step = GeomtricPathBuilder.buildGeometricStep(previousLeg, currentLeg);
-
- console.log(step);
-
- currentLegIdx--;
- currentLeg = previousLeg;
- [previousLeg, currentLegIdx] = GeomtricPathBuilder.findSlopeStart(geometry, currentLegIdx);
- }
-
- return fpaTable as GeometricPath;
- }
-
- /**
- * Finds the next valid slope segment for the geometrical path.
- *
- * @param geometry lateral geometry to use
- * @param startLegIdx the leg index to serve as the end of the slope
- *
- * @return the leg that starts the slope, and the index of that leg
- */
- private static findSlopeStart(geometry: Geometry, startLegIdx: number): [Leg, number] | undefined {
- let searchIdx = startLegIdx - 1;
- let searchLeg = geometry.legs.get(searchIdx);
- while (searchLeg) {
- if (searchLeg.altitudeConstraint) {
- break;
- }
-
- searchIdx--;
- searchLeg = geometry.legs.get(searchIdx);
- }
-
- return [searchLeg, searchIdx];
- }
-
- private static buildGeometricStep(
- leg1: Leg,
- leg2: Leg,
- ): StepResults {
- const geometricalStepResult = Predictions.geometricStep(
- leg1.altitudeConstraint.altitude2 || leg1.altitudeConstraint.altitude1,
- leg2.altitudeConstraint.altitude2 || leg2.altitudeConstraint.altitude1,
- leg2.distance, // this should include transition from leg1 to leg2
- 200,
- 0.6,
- 70_000,
- 72_750,
- 0,
- 36_000,
- );
-
- return geometricalStepResult;
- }
-}
diff --git a/src/fmgc/src/guidance/vnav/descent/InertialDistanceAlongTrack.ts b/src/fmgc/src/guidance/vnav/descent/InertialDistanceAlongTrack.ts
new file mode 100644
index 000000000000..8da32be0909a
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/descent/InertialDistanceAlongTrack.ts
@@ -0,0 +1,24 @@
+export class InertialDistanceAlongTrack {
+ private lastUpdate: number = 0;
+
+ private currentDistanceAlongTrack: number = 0;
+
+ updateCorrectInformation(actualDistanceAlongTrack: NauticalMiles) {
+ this.currentDistanceAlongTrack = actualDistanceAlongTrack;
+
+ this.lastUpdate = Date.now();
+ }
+
+ update() {
+ // Should probably use ADR data here
+ const groundSpeed = SimVar.GetSimVarValue('GPS GROUND SPEED', 'Knots');
+
+ this.currentDistanceAlongTrack += groundSpeed * (Date.now() - this.lastUpdate) / 1000 / 60 / 60;
+
+ this.lastUpdate = Date.now();
+ }
+
+ get() {
+ return this.currentDistanceAlongTrack;
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/descent/LatchedDescentGuidance.ts b/src/fmgc/src/guidance/vnav/descent/LatchedDescentGuidance.ts
new file mode 100644
index 000000000000..c87c4759b59c
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/descent/LatchedDescentGuidance.ts
@@ -0,0 +1,253 @@
+import { RequestedVerticalMode, TargetAltitude, TargetVerticalSpeed } from '@fmgc/guidance/ControlLaws';
+import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions';
+import { AircraftToDescentProfileRelation } from '@fmgc/guidance/vnav/descent/AircraftToProfileRelation';
+import { NavGeometryProfile } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { VerticalMode } from '@shared/autopilot';
+import { FmgcFlightPhase } from '@shared/flightphase';
+import { SpeedMargin } from './SpeedMargin';
+
+enum DescentVerticalGuidanceState {
+ InvalidProfile,
+ ProvidingGuidance,
+ Observing
+}
+
+enum DescentSpeedGuidanceState {
+ NotInDescentPhase,
+ TargetOnly,
+ TargetAndMargins,
+}
+
+export class LatchedDescentGuidance {
+ private verticalState: DescentVerticalGuidanceState = DescentVerticalGuidanceState.InvalidProfile;
+
+ private speedState: DescentSpeedGuidanceState = DescentSpeedGuidanceState.NotInDescentPhase;
+
+ private requestedVerticalMode: RequestedVerticalMode = RequestedVerticalMode.None;
+
+ private targetAltitude: TargetAltitude = 0;
+
+ private targetAltitudeGuidance: TargetAltitude = 0;
+
+ private targetVerticalSpeed: TargetVerticalSpeed = 0;
+
+ private showLinearDeviationOnPfd: boolean = false;
+
+ private showDescentLatchOnPfd: boolean = false;
+
+ private speedMargin: SpeedMargin;
+
+ private speedTarget: Knots | Mach;
+
+ private tdReached: boolean;
+
+ // 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
+ private isInOverspeedCondition: boolean = false;
+
+ constructor(
+ private aircraftToDescentProfileRelation: AircraftToDescentProfileRelation,
+ private observer: VerticalProfileComputationParametersObserver,
+ private atmosphericConditions: AtmosphericConditions,
+ ) {
+ this.speedMargin = new SpeedMargin(this.observer);
+
+ this.writeToSimVars();
+ }
+
+ updateProfile(profile: NavGeometryProfile) {
+ this.aircraftToDescentProfileRelation.updateProfile(profile);
+
+ if (!this.aircraftToDescentProfileRelation.isValid) {
+ this.changeState(DescentVerticalGuidanceState.InvalidProfile);
+ }
+ }
+
+ private changeState(newState: DescentVerticalGuidanceState) {
+ if (this.verticalState === newState) {
+ return;
+ }
+
+ if (this.verticalState !== DescentVerticalGuidanceState.InvalidProfile && newState === DescentVerticalGuidanceState.InvalidProfile) {
+ this.reset();
+ this.writeToSimVars();
+ }
+
+ this.verticalState = newState;
+ }
+
+ private reset() {
+ this.requestedVerticalMode = RequestedVerticalMode.None;
+ this.targetAltitude = 0;
+ this.targetVerticalSpeed = 0;
+ this.showLinearDeviationOnPfd = false;
+ this.showDescentLatchOnPfd = false;
+ this.isInOverspeedCondition = false;
+ }
+
+ update() {
+ this.aircraftToDescentProfileRelation.update();
+
+ if (!this.aircraftToDescentProfileRelation.isValid) {
+ return;
+ }
+
+ if ((this.observer.get().fcuVerticalMode === VerticalMode.DES) !== (this.verticalState === DescentVerticalGuidanceState.ProvidingGuidance)) {
+ this.changeState(this.verticalState === DescentVerticalGuidanceState.ProvidingGuidance ? DescentVerticalGuidanceState.Observing : DescentVerticalGuidanceState.ProvidingGuidance);
+ }
+ this.updateSpeedMarginState();
+
+ this.updateSpeedTarget();
+ this.updateSpeedGuidance();
+ this.updateOverspeedCondition();
+ this.updateLinearDeviation();
+
+ if (this.verticalState === DescentVerticalGuidanceState.ProvidingGuidance) {
+ this.updateDesModeGuidance();
+ }
+
+ 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);
+ }
+ }
+
+ private updateLinearDeviation() {
+ this.targetAltitude = this.aircraftToDescentProfileRelation.currentTargetAltitude();
+
+ this.showLinearDeviationOnPfd = this.observer.get().flightPhase >= FmgcFlightPhase.Descent || this.aircraftToDescentProfileRelation.isPastTopOfDescent();
+ }
+
+ private updateDesModeGuidance() {
+ const isAboveSpeedLimitAltitude = this.aircraftToDescentProfileRelation.isAboveSpeedLimitAltitude();
+ const isBeforeTopOfDescent = !this.aircraftToDescentProfileRelation.isPastTopOfDescent();
+ const linearDeviation = this.aircraftToDescentProfileRelation.computeLinearDeviation();
+
+ this.targetAltitudeGuidance = this.atmosphericConditions.estimatePressureAltitudeInMsfs(
+ this.aircraftToDescentProfileRelation.currentTargetAltitude(),
+ );
+
+ if (linearDeviation > 200 || this.isInOverspeedCondition) {
+ // Above path
+ this.requestedVerticalMode = RequestedVerticalMode.SpeedThrust;
+ this.showDescentLatchOnPfd = false;
+ } else if (isBeforeTopOfDescent || linearDeviation < -200) {
+ // Below path
+ this.requestedVerticalMode = RequestedVerticalMode.VsSpeed;
+ this.targetVerticalSpeed = (isAboveSpeedLimitAltitude ? -1000 : -500);
+ this.showDescentLatchOnPfd = false;
+ } else {
+ // On path
+ this.requestedVerticalMode = RequestedVerticalMode.VpathSpeed;
+ this.targetVerticalSpeed = this.aircraftToDescentProfileRelation.currentTargetVerticalSpeed();
+ this.showDescentLatchOnPfd = true;
+ }
+ }
+
+ private updateSpeedTarget() {
+ const { fcuSpeed, managedDescentSpeedMach } = this.observer.get();
+ const inManagedSpeed = Simplane.getAutoPilotAirspeedManaged();
+
+ this.speedTarget = inManagedSpeed
+ ? Math.round(this.iasOrMach(this.aircraftToDescentProfileRelation.currentTargetSpeed(), managedDescentSpeedMach))
+ : fcuSpeed;
+ }
+
+ private writeToSimVars() {
+ SimVar.SetSimVarValue('L:A32NX_FG_REQUESTED_VERTICAL_MODE', 'Enum', this.requestedVerticalMode);
+ SimVar.SetSimVarValue('L:A32NX_FG_TARGET_ALTITUDE', 'Feet', this.targetAltitudeGuidance);
+ SimVar.SetSimVarValue('L:A32NX_FG_TARGET_VERTICAL_SPEED', 'number', this.targetVerticalSpeed);
+
+ SimVar.SetSimVarValue('L:A32NX_PFD_TARGET_ALTITUDE', 'Feet', this.targetAltitude);
+ SimVar.SetSimVarValue('L:A32NX_PFD_LINEAR_DEVIATION_ACTIVE', 'Bool', this.showLinearDeviationOnPfd);
+ SimVar.SetSimVarValue('L:A32NX_PFD_VERTICAL_PROFILE_LATCHED', 'Bool', this.showDescentLatchOnPfd);
+ }
+
+ private updateSpeedGuidance() {
+ if (this.speedState === DescentSpeedGuidanceState.NotInDescentPhase) {
+ return;
+ }
+
+ SimVar.SetSimVarValue('L:A32NX_SPEEDS_MANAGED_PFD', 'knots', this.speedTarget);
+
+ const guidanceTarget = this.speedState === DescentSpeedGuidanceState.TargetAndMargins
+ ? this.speedMargin.getMargins(this.speedTarget)[1]
+ : this.speedTarget;
+ SimVar.SetSimVarValue('L:A32NX_SPEEDS_MANAGED_ATHR', 'knots', guidanceTarget);
+
+ if (this.speedState === DescentSpeedGuidanceState.TargetAndMargins) {
+ const [lower, upper] = this.speedMargin.getMargins(this.speedTarget);
+
+ SimVar.SetSimVarValue('L:A32NX_PFD_LOWER_SPEED_MARGIN', 'Knots', lower);
+ SimVar.SetSimVarValue('L:A32NX_PFD_UPPER_SPEED_MARGIN', 'Knots', upper);
+ }
+ }
+
+ private updateSpeedMarginState() {
+ const { flightPhase } = this.observer.get();
+
+ if (flightPhase !== FmgcFlightPhase.Descent) {
+ this.changeSpeedState(DescentSpeedGuidanceState.NotInDescentPhase);
+ return;
+ }
+
+ const shouldShowMargins = this.verticalState === DescentVerticalGuidanceState.ProvidingGuidance && Simplane.getAutoPilotAirspeedManaged() && !this.showDescentLatchOnPfd;
+ this.changeSpeedState(shouldShowMargins ? DescentSpeedGuidanceState.TargetAndMargins : DescentSpeedGuidanceState.TargetOnly);
+ }
+
+ private changeSpeedState(newState: DescentSpeedGuidanceState) {
+ if (this.speedState === newState) {
+ return;
+ }
+
+ // Hide margins if they were previously visible, but the state changed to literally anything else
+ if (this.speedState === DescentSpeedGuidanceState.TargetAndMargins) {
+ SimVar.SetSimVarValue('L:A32NX_PFD_SHOW_SPEED_MARGINS', 'boolean', false);
+ SimVar.SetSimVarValue('L:A32NX_PFD_LOWER_SPEED_MARGIN', 'Knots', 0);
+ SimVar.SetSimVarValue('L:A32NX_PFD_UPPER_SPEED_MARGIN', 'Knots', 0);
+ } else if (newState === DescentSpeedGuidanceState.TargetAndMargins) {
+ SimVar.SetSimVarValue('L:A32NX_PFD_SHOW_SPEED_MARGINS', 'boolean', true);
+ }
+
+ this.speedState = newState;
+ }
+
+ private iasOrMach(ias: Knots, mach: Mach) {
+ const machAsIas = SimVar.GetGameVarValue('FROM MACH TO KIAS', 'number', mach);
+
+ if (ias > machAsIas) {
+ return machAsIas;
+ }
+
+ return ias;
+ }
+
+ private updateOverspeedCondition() {
+ const airspeed = this.atmosphericConditions.currentAirspeed;
+
+ let overspeedResolutionSpeed = this.speedTarget;
+ if (this.speedState === DescentSpeedGuidanceState.TargetAndMargins) {
+ const [_, upper] = this.speedMargin.getMargins(this.speedTarget);
+ overspeedResolutionSpeed = upper;
+ }
+
+ const overspeedTriggerSpeed = this.iasOrMach(345, 0.81);
+
+ if (this.isInOverspeedCondition && airspeed < overspeedResolutionSpeed) {
+ this.isInOverspeedCondition = false;
+ } if (!this.isInOverspeedCondition && airspeed > overspeedTriggerSpeed) {
+ this.isInOverspeedCondition = true;
+ }
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/descent/ProfileInterceptCalculator.ts b/src/fmgc/src/guidance/vnav/descent/ProfileInterceptCalculator.ts
new file mode 100644
index 000000000000..9486ae90b929
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/descent/ProfileInterceptCalculator.ts
@@ -0,0 +1,27 @@
+import { VerticalCheckpoint } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { MathUtils } from '@shared/MathUtils';
+
+export class ProfileInterceptCalculator {
+ static calculateIntercept(checkpoints1: VerticalCheckpoint[], checkpoints2: VerticalCheckpoint[]): NauticalMiles | null {
+ for (let i = 0; i < checkpoints1.length - 1; i++) {
+ const c1Start = checkpoints1[i];
+ const c1End = checkpoints1[i + 1];
+
+ for (let j = 0; j < checkpoints2.length - 1; j++) {
+ const c2Start = checkpoints2[j];
+ const c2End = checkpoints2[j + 1];
+
+ const intersection = MathUtils.intersect(
+ c1Start.distanceFromStart, c1Start.altitude, c1End.distanceFromStart, c1End.altitude,
+ c2Start.distanceFromStart, c2Start.altitude, c2End.distanceFromStart, c2End.altitude,
+ );
+
+ if (intersection) {
+ return intersection[0];
+ }
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/descent/SpeedMargin.ts b/src/fmgc/src/guidance/vnav/descent/SpeedMargin.ts
new file mode 100644
index 000000000000..81ba035eacf4
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/descent/SpeedMargin.ts
@@ -0,0 +1,33 @@
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+
+export class SpeedMargin {
+ private vmo: Knots = 350;
+
+ private mmo: Mach = 0.82;
+
+ constructor(private observer: VerticalProfileComputationParametersObserver) { }
+
+ getTarget(indicatedAirspeed: Knots, targetSpeed: Knots): Knots {
+ const [lowerMargin, upperMargin] = this.getMargins(targetSpeed);
+
+ return Math.max(Math.min(indicatedAirspeed, upperMargin), lowerMargin);
+ }
+
+ getMargins(currentTarget: Knots): [Knots, Knots] {
+ const vmax = SimVar.GetSimVarValue('L:A32NX_SPEEDS_VMAX', 'number');
+
+ const vls = SimVar.GetSimVarValue('L:A32NX_SPEEDS_VLS', 'number');
+ const f = SimVar.GetSimVarValue('L:A32NX_SPEEDS_F', 'number');
+ const s = SimVar.GetSimVarValue('L:A32NX_SPEEDS_S', 'number');
+ const vmin = Math.max(vls, f, s);
+
+ const mmoAsIas = SimVar.GetGameVarValue('FROM MACH TO KIAS', 'number', this.mmo);
+
+ const distanceToUpperMargin = this.observer.get().managedDescentSpeed - currentTarget > 1 ? 5 : 20;
+
+ return [
+ Math.max(vmin, Math.min(currentTarget - 20, vmax, this.vmo - 3, mmoAsIas - 0.006)),
+ Math.max(vmin, Math.min(vmax, this.vmo - 3, mmoAsIas - 0.006, currentTarget + distanceToUpperMargin)),
+ ];
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/descent/TacticalDescentPathBuilder.ts b/src/fmgc/src/guidance/vnav/descent/TacticalDescentPathBuilder.ts
new file mode 100644
index 000000000000..4c421733e04c
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/descent/TacticalDescentPathBuilder.ts
@@ -0,0 +1,206 @@
+import { ManagedSpeedType, SpeedProfile } from '@fmgc/guidance/vnav/climb/SpeedProfile';
+import { DescentStrategy } from '@fmgc/guidance/vnav/descent/DescentStrategy';
+import { StepResults } from '@fmgc/guidance/vnav/Predictions';
+import { BaseGeometryProfile } from '@fmgc/guidance/vnav/profile/BaseGeometryProfile';
+import { MaxSpeedConstraint, VerticalCheckpoint, VerticalCheckpointReason } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { WindComponent } from '@fmgc/guidance/vnav/wind';
+
+export class TacticalDescentPathBuilder {
+ constructor(private observer: VerticalProfileComputationParametersObserver) { }
+
+ /**
+ * Builds a path from the last checkpoint to the finalAltitude
+ * @param profile
+ * @param descentStrategy
+ * @param speedProfile
+ * @param finalAltitude
+ */
+ buildTacticalDescentPath(profile: BaseGeometryProfile, descentStrategy: DescentStrategy, speedProfile: SpeedProfile, finalAltitude: Feet) {
+ const constraintsToUse = profile.descentSpeedConstraints
+ .slice()
+ .reverse()
+ .sort((a, b) => a.distanceFromStart - b.distanceFromStart);
+
+ const planner = new TacticalDescentPathPlanner(
+ this.observer, speedProfile, descentStrategy, constraintsToUse, profile.lastCheckpoint, finalAltitude,
+ );
+
+ for (let i = 0; i < 100 && planner.currentConstraintIndex < constraintsToUse.length; i++) {
+ planner.stepAlong();
+ }
+
+ planner.planAltitudeStep();
+
+ const checkpointsToAdd = planner.finalize();
+
+ profile.checkpoints.push(...checkpointsToAdd);
+ }
+}
+
+class TacticalDescentPathPlanner {
+ public currentConstraintIndex: number = 0;
+
+ private steps: StepResults[] = []
+
+ public currentCheckpoint: VerticalCheckpoint = null;
+
+ private decelerationDistancesByConstraint: NauticalMiles[]
+
+ private maximumDecelerationDistances: NauticalMiles[]
+
+ constructor(
+ private observer: VerticalProfileComputationParametersObserver,
+ private speedProfile: SpeedProfile,
+ private descentStrategy: DescentStrategy,
+ private constraints: MaxSpeedConstraint[],
+ private startingPoint: VerticalCheckpoint,
+ private finalAltitude: Feet,
+ ) {
+ this.currentCheckpoint = { ...startingPoint };
+
+ this.decelerationDistancesByConstraint = new Array(this.constraints.length);
+
+ this.maximumDecelerationDistances = this.findMaximumDecelerationDistances();
+ }
+
+ stepAlong() {
+ const constraintAlongTrack = this.constraints[this.currentConstraintIndex];
+
+ // If the constraint is before where we are now, just move along
+ if (constraintAlongTrack.distanceFromStart > this.currentCheckpoint.distanceFromStart) {
+ this.currentConstraintIndex++;
+
+ return;
+ }
+
+ if (!this.decelerationDistancesByConstraint[this.currentConstraintIndex]) {
+ this.decelerationDistancesByConstraint[this.currentConstraintIndex] = 0;
+ }
+
+ const indexToResetTo = this.currentConstraintIndex;
+
+ this.planDistanceStep();
+ this.planDecelerationStep();
+
+ const overshoot = this.currentCheckpoint.distanceFromStart - constraintAlongTrack.distanceFromStart;
+
+ const decelerationDistance = this.decelerationDistancesByConstraint[this.currentConstraintIndex];
+ const maximumDecelerationDistance = this.maximumDecelerationDistances[this.currentConstraintIndex];
+ const yetAvailableDecelerationDistance = maximumDecelerationDistance - decelerationDistance;
+
+ // We overshoot in distance
+ if (overshoot > 0.1 && yetAvailableDecelerationDistance > 0.1) {
+ // Plan more decel distance next time
+ this.decelerationDistancesByConstraint[this.currentConstraintIndex] = Math.min(
+ decelerationDistance + overshoot,
+ maximumDecelerationDistance,
+ );
+
+ this.resetToIndex(indexToResetTo);
+ } else {
+ this.currentConstraintIndex++;
+ }
+ }
+
+ findMaximumDecelerationDistances() {
+ return this.constraints.map((constraint, index) => (index > 1 ? constraint.distanceFromStart - this.constraints[index - 1].distanceFromStart : 0));
+ }
+
+ planDistanceStep() {
+ const { managedDescentSpeedMach } = this.observer.get();
+ const { distanceFromStart, altitude, remainingFuelOnBoard } = this.currentCheckpoint;
+ const constraintAlongTrack = this.constraints[this.currentConstraintIndex];
+
+ const distanceForDistanceStep = Math.max(0, constraintAlongTrack.distanceFromStart - distanceFromStart);
+
+ const distanceStep = this.descentStrategy.predictToDistance(
+ altitude,
+ distanceForDistanceStep,
+ this.speedProfile.getTarget(distanceFromStart, altitude, ManagedSpeedType.Descent),
+ managedDescentSpeedMach,
+ remainingFuelOnBoard,
+ WindComponent.zero(),
+ );
+
+ this.steps.push(distanceStep);
+ }
+
+ planDecelerationStep() {
+ const { managedDescentSpeedMach } = this.observer.get();
+ const { altitude, remainingFuelOnBoard, speed } = this.currentCheckpoint;
+ const constraintAlongTrack = this.constraints[this.currentConstraintIndex];
+
+ const targetSpeed = this.speedProfile.getTarget(constraintAlongTrack.distanceFromStart, altitude, ManagedSpeedType.Descent);
+ if (speed - targetSpeed < 1) {
+ return;
+ }
+
+ const decelerationStep = this.descentStrategy.predictToSpeed(
+ altitude,
+ speed,
+ targetSpeed,
+ managedDescentSpeedMach,
+ remainingFuelOnBoard,
+ WindComponent.zero(),
+ );
+
+ this.steps.push(decelerationStep);
+ }
+
+ planAltitudeStep() {
+ const { managedDescentSpeedMach } = this.observer.get();
+ const { altitude, remainingFuelOnBoard, speed } = this.currentCheckpoint;
+
+ if (altitude < this.finalAltitude) {
+ return;
+ }
+
+ const step = this.descentStrategy.predictToAltitude(
+ altitude,
+ this.finalAltitude,
+ speed,
+ managedDescentSpeedMach,
+ remainingFuelOnBoard,
+ WindComponent.zero(),
+ );
+
+ this.steps.push(step);
+ }
+
+ resetToIndex(index: number) {
+ this.steps.splice(index);
+ this.currentConstraintIndex = index;
+
+ this.currentCheckpoint = this.startingPoint;
+ for (let i = 0; i < index; i++) {
+ this.currentCheckpoint = this.updateCheckpointFromStep(this.currentCheckpoint, this.steps[i]);
+ }
+ }
+
+ finalize(): VerticalCheckpoint[] {
+ const checkpoints: VerticalCheckpoint[] = [];
+ let newCheckpoint = this.startingPoint;
+
+ for (const step of this.steps) {
+ newCheckpoint = this.updateCheckpointFromStep(newCheckpoint, step);
+
+ checkpoints.push(newCheckpoint);
+ }
+
+ checkpoints.push({ ...checkpoints[checkpoints.length - 1], reason: VerticalCheckpointReason.CrossingFcuAltitudeDescent });
+
+ return checkpoints;
+ }
+
+ private updateCheckpointFromStep(checkpoint: VerticalCheckpoint, step: StepResults): VerticalCheckpoint {
+ return {
+ reason: VerticalCheckpointReason.IdlePathAtmosphericConditions,
+ altitude: step.finalAltitude,
+ distanceFromStart: checkpoint.distanceFromStart + step.distanceTraveled,
+ remainingFuelOnBoard: checkpoint.remainingFuelOnBoard - step.fuelBurned,
+ secondsFromPresent: checkpoint.secondsFromPresent + step.timeElapsed,
+ speed: step.speed,
+ };
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/descent/TheoreticalDescentPath.ts b/src/fmgc/src/guidance/vnav/descent/TheoreticalDescentPath.ts
deleted file mode 100644
index 01a35990fa84..000000000000
--- a/src/fmgc/src/guidance/vnav/descent/TheoreticalDescentPath.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * Theoretical descent path model
- */
-export interface TheoreticalDescentPathCharacteristics {
- tod: number,
-}
-
-export interface IdlePath {
- speedLimitStartDistanceFromEnd: NauticalMiles,
- speedLimitValue: Knots,
-}
-
-export interface GeometricPath {
- /**
- * Table of flight path angles indexed by the leg whose termination they end up at
- */
- flightPathAngles: {
- [k: number]: Degrees,
- },
-}
diff --git a/src/fmgc/src/guidance/vnav/profile/BaseGeometryProfile.ts b/src/fmgc/src/guidance/vnav/profile/BaseGeometryProfile.ts
new file mode 100644
index 000000000000..3b65b9b3b087
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/profile/BaseGeometryProfile.ts
@@ -0,0 +1,502 @@
+import { Common } from '@fmgc/guidance/vnav/common';
+import { PseudoWaypointFlightPlanInfo } from '@fmgc/guidance/PseudoWaypoint';
+import { DescentAltitudeConstraint, MaxAltitudeConstraint, MaxSpeedConstraint, VerticalCheckpoint, VerticalCheckpointReason } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { MathUtils } from '@shared/MathUtils';
+
+export interface PerformancePagePrediction {
+ altitude: Feet,
+ distanceFromStart: NauticalMiles,
+ secondsFromPresent: Seconds,
+}
+
+export abstract class BaseGeometryProfile {
+ public isReadyToDisplay: boolean = false;
+
+ public checkpoints: VerticalCheckpoint[] = [];
+
+ abstract get maxAltitudeConstraints(): MaxAltitudeConstraint[];
+
+ abstract get descentAltitudeConstraints(): DescentAltitudeConstraint[];
+
+ abstract get maxClimbSpeedConstraints(): MaxSpeedConstraint[];
+
+ abstract get descentSpeedConstraints(): MaxSpeedConstraint[];
+
+ abstract get distanceToPresentPosition(): NauticalMiles;
+
+ get lastCheckpoint(): VerticalCheckpoint | null {
+ if (this.checkpoints.length < 1) {
+ return null;
+ }
+
+ return this.checkpoints[this.checkpoints.length - 1];
+ }
+
+ addCheckpointFromLast(checkpointBuilder: (lastCheckpoint: VerticalCheckpoint) => Partial) {
+ this.checkpoints.push({ ...this.lastCheckpoint, ...checkpointBuilder(this.lastCheckpoint) });
+ }
+
+ predictAtTime(secondsFromPresent: Seconds): PseudoWaypointFlightPlanInfo {
+ const distanceFromStart = this.interpolateDistanceAtTime(secondsFromPresent);
+ const { altitude, speed } = this.interpolateEverythingFromStart(distanceFromStart);
+
+ return {
+ distanceFromStart,
+ altitude,
+ speed,
+ secondsFromPresent,
+ };
+ }
+
+ private interpolateFromCheckpoints(
+ indexValue: T, keySelector: (checkpoint: VerticalCheckpoint) => T, valueSelector: (checkpoint: VerticalCheckpoint) => U,
+ ) {
+ if (indexValue <= keySelector(this.checkpoints[0])) {
+ return valueSelector(this.checkpoints[0]);
+ }
+
+ for (let i = 0; i < this.checkpoints.length - 1; i++) {
+ if (indexValue > keySelector(this.checkpoints[i]) && indexValue <= keySelector(this.checkpoints[i + 1])) {
+ return Common.interpolate(
+ indexValue,
+ keySelector(this.checkpoints[i]),
+ keySelector(this.checkpoints[i + 1]),
+ valueSelector(this.checkpoints[i]),
+ valueSelector(this.checkpoints[i + 1]),
+ );
+ }
+ }
+
+ return valueSelector(this.checkpoints[this.checkpoints.length - 1]);
+ }
+
+ private interpolateFromCheckpointsBackwards(
+ indexValue: T, keySelector: (checkpoint: VerticalCheckpoint) => T, valueSelector: (checkpoint: VerticalCheckpoint) => U,
+ ) {
+ if (indexValue < keySelector(this.checkpoints[this.checkpoints.length - 1])) {
+ return valueSelector(this.checkpoints[this.checkpoints.length - 1]);
+ }
+
+ for (let i = this.checkpoints.length - 2; i >= 0; i--) {
+ if (indexValue <= keySelector(this.checkpoints[i]) && indexValue > keySelector(this.checkpoints[i + 1])) {
+ return Common.interpolate(
+ indexValue,
+ keySelector(this.checkpoints[i]),
+ keySelector(this.checkpoints[i + 1]),
+ valueSelector(this.checkpoints[i]),
+ valueSelector(this.checkpoints[i + 1]),
+ );
+ }
+ }
+
+ return valueSelector(this.checkpoints[0]);
+ }
+
+ /**
+ * Find the time from start at which the profile predicts us to be at a distance along the flightplan.
+ * @param distanceFromStart Distance along that path
+ * @returns Predicted altitude
+ */
+ interpolateTimeAtDistance(distanceFromStart: NauticalMiles): Seconds {
+ return this.interpolateFromCheckpoints(distanceFromStart, (checkpoint) => checkpoint.distanceFromStart, (checkpoint) => checkpoint.secondsFromPresent);
+ }
+
+ /**
+ * Find the altitude at which the profile predicts us to be at a distance along the flightplan.
+ * @param distanceFromStart Distance along that path
+ * @returns Predicted altitude
+ */
+ interpolateAltitudeAtDistance(distanceFromStart: NauticalMiles): Feet {
+ return this.interpolateFromCheckpoints(distanceFromStart, (checkpoint) => checkpoint.distanceFromStart, (checkpoint) => checkpoint.altitude);
+ }
+
+ /**
+ * Find the speed at which the profile predicts us to be at a distance along the flightplan.
+ * @param distanceFromStart Distance along that path
+ * @returns Predicted speed
+ */
+ interpolateSpeedAtDistance(distanceFromStart: NauticalMiles): Feet {
+ return this.interpolateFromCheckpoints(distanceFromStart, (checkpoint) => checkpoint.distanceFromStart, (checkpoint) => checkpoint.speed);
+ }
+
+ /**
+ * Find the distanceFromStart at which the profile predicts us to be at a time since departure
+ * @param secondsFromPresent Time since departure
+ * @returns Predicted distance
+ */
+ interpolateDistanceAtTime(secondsFromPresent: Seconds): NauticalMiles {
+ return this.interpolateFromCheckpoints(secondsFromPresent, (checkpoint) => checkpoint.secondsFromPresent, (checkpoint) => checkpoint.distanceFromStart);
+ }
+
+ interpolateEverythingFromStart(distanceFromStart: NauticalMiles, doInterpolateAltitude = true): Omit {
+ if (distanceFromStart <= this.checkpoints[0].distanceFromStart) {
+ return {
+ distanceFromStart,
+ secondsFromPresent: this.checkpoints[0].secondsFromPresent,
+ altitude: this.checkpoints[0].altitude,
+ remainingFuelOnBoard: this.checkpoints[0].remainingFuelOnBoard,
+ speed: this.checkpoints[0].speed,
+ mach: this.checkpoints[0].mach,
+ };
+ }
+
+ for (let i = 0; i < this.checkpoints.length - 1; i++) {
+ if (distanceFromStart > this.checkpoints[i].distanceFromStart && distanceFromStart <= this.checkpoints[i + 1].distanceFromStart) {
+ return {
+ distanceFromStart,
+ secondsFromPresent: Common.interpolate(
+ distanceFromStart,
+ this.checkpoints[i].distanceFromStart,
+ this.checkpoints[i + 1].distanceFromStart,
+ this.checkpoints[i].secondsFromPresent,
+ this.checkpoints[i + 1].secondsFromPresent,
+ ),
+ altitude: doInterpolateAltitude ? Common.interpolate(
+ distanceFromStart,
+ this.checkpoints[i].distanceFromStart,
+ this.checkpoints[i + 1].distanceFromStart,
+ this.checkpoints[i].altitude,
+ this.checkpoints[i + 1].altitude,
+ ) : this.checkpoints[i].altitude,
+ remainingFuelOnBoard: Common.interpolate(
+ distanceFromStart,
+ this.checkpoints[i].distanceFromStart,
+ this.checkpoints[i + 1].distanceFromStart,
+ this.checkpoints[i].remainingFuelOnBoard,
+ this.checkpoints[i + 1].remainingFuelOnBoard,
+ ),
+ speed: Common.interpolate(
+ distanceFromStart,
+ this.checkpoints[i].distanceFromStart,
+ this.checkpoints[i + 1].distanceFromStart,
+ this.checkpoints[i].speed,
+ this.checkpoints[i + 1].speed,
+ ),
+ mach: Common.interpolate(
+ distanceFromStart,
+ this.checkpoints[i].distanceFromStart,
+ this.checkpoints[i + 1].distanceFromStart,
+ this.checkpoints[i].mach,
+ this.checkpoints[i + 1].mach,
+ ),
+ };
+ }
+ }
+
+ return {
+ distanceFromStart,
+ secondsFromPresent: this.lastCheckpoint.secondsFromPresent,
+ altitude: this.lastCheckpoint.altitude,
+ remainingFuelOnBoard: this.lastCheckpoint.remainingFuelOnBoard,
+ speed: this.lastCheckpoint.speed,
+ mach: this.lastCheckpoint.mach,
+ };
+ }
+
+ interpolateDistanceAtAltitude(altitude: Feet): NauticalMiles {
+ return this.interpolateFromCheckpoints(altitude, (checkpoint) => checkpoint.altitude, (checkpoint) => checkpoint.distanceFromStart);
+ }
+
+ interpolateDistanceAtAltitudeBackwards(altitude: Feet): NauticalMiles {
+ return this.interpolateFromCheckpointsBackwards(altitude, (checkpoint) => checkpoint.altitude, (checkpoint) => checkpoint.distanceFromStart);
+ }
+
+ interpolateFuelAtDistance(distance: NauticalMiles): NauticalMiles {
+ return this.interpolateFromCheckpoints(distance, (checkpoint) => checkpoint.distanceFromStart, (checkpoint) => checkpoint.remainingFuelOnBoard);
+ }
+
+ interpolatePathAngleAtDistance(distanceFromStart: NauticalMiles): Degrees {
+ if (distanceFromStart < this.checkpoints[0].distanceFromStart) {
+ return 0;
+ }
+
+ for (let i = 0; i < this.checkpoints.length - 1; i++) {
+ if (distanceFromStart > this.checkpoints[i].distanceFromStart && distanceFromStart <= this.checkpoints[i + 1].distanceFromStart) {
+ return MathUtils.RADIANS_TO_DEGREES * Math.atan(
+ (this.checkpoints[i + 1].altitude - this.checkpoints[i].altitude)
+ / (this.checkpoints[i + 1].distanceFromStart - this.checkpoints[i].distanceFromStart) / 6076.12,
+ );
+ }
+ }
+
+ return 0;
+ }
+
+ findVerticalCheckpoint(...reasons: VerticalCheckpointReason[]): VerticalCheckpoint | undefined {
+ return this.checkpoints.find((checkpoint) => reasons.includes(checkpoint.reason));
+ }
+
+ findLastVerticalCheckpoint(...reasons: VerticalCheckpointReason[]): VerticalCheckpoint | undefined {
+ return [...this.checkpoints].reverse().find((checkpoint) => reasons.includes(checkpoint.reason));
+ }
+
+ findLastVerticalCheckpointIndex(...reasons: VerticalCheckpointReason[]): number {
+ return findLastIndex(this.checkpoints, ({ reason }) => reasons.includes(reason));
+ }
+
+ purgeVerticalCheckpoints(reason: VerticalCheckpointReason): void {
+ this.checkpoints = this.checkpoints.filter((checkpoint) => checkpoint.reason !== reason);
+ }
+
+ // TODO: We shouldn't have to go looking for this here...
+ // This logic probably belongs to `ClimbPathBuilder`.
+ findSpeedLimitCrossing(): [NauticalMiles, Knots] | undefined {
+ const speedLimit = this.checkpoints.find((checkpoint) => checkpoint.reason === VerticalCheckpointReason.CrossingClimbSpeedLimit);
+
+ if (!speedLimit) {
+ return undefined;
+ }
+
+ return [speedLimit.distanceFromStart, speedLimit.speed];
+ }
+
+ // TODO: Make this not iterate over map
+ findDistancesToSpeedChanges(): NauticalMiles[] {
+ const result: NauticalMiles[] = [];
+
+ const speedLimitCrossing = this.findSpeedLimitCrossing();
+ if (!speedLimitCrossing) {
+ return [];
+ }
+
+ const [speedLimitDistance, _] = speedLimitCrossing;
+ result.push(speedLimitDistance);
+
+ return result;
+ }
+
+ findNextSpeedTarget(distanceFromStart: NauticalMiles): Knots {
+ if (distanceFromStart < this.checkpoints[0].distanceFromStart) {
+ return this.checkpoints[0].speed;
+ }
+
+ for (let i = 0; i < this.checkpoints.length - 1; i++) {
+ if (distanceFromStart > this.checkpoints[i].distanceFromStart && distanceFromStart <= this.checkpoints[i + 1].distanceFromStart) {
+ return this.checkpoints[i + 1].speed;
+ }
+ }
+
+ return this.lastCheckpoint.speed;
+ }
+
+ addInterpolatedCheckpoint(distanceFromStart: NauticalMiles, additionalProperties: HasAtLeast) {
+ if (distanceFromStart <= this.checkpoints[0].distanceFromStart) {
+ this.checkpoints.unshift({
+ distanceFromStart,
+ secondsFromPresent: this.checkpoints[0].secondsFromPresent,
+ altitude: this.checkpoints[0].altitude,
+ remainingFuelOnBoard: this.checkpoints[0].remainingFuelOnBoard,
+ speed: this.checkpoints[0].speed,
+ mach: this.checkpoints[0].mach,
+ ...additionalProperties,
+ });
+
+ return;
+ }
+
+ for (let i = 0; i < this.checkpoints.length - 1; i++) {
+ if (distanceFromStart > this.checkpoints[i].distanceFromStart && distanceFromStart <= this.checkpoints[i + 1].distanceFromStart) {
+ this.checkpoints.splice(i + 1, 0, {
+ distanceFromStart,
+ secondsFromPresent: Common.interpolate(
+ distanceFromStart,
+ this.checkpoints[i].distanceFromStart,
+ this.checkpoints[i + 1].distanceFromStart,
+ this.checkpoints[i].secondsFromPresent,
+ this.checkpoints[i + 1].secondsFromPresent,
+ ),
+ altitude: Common.interpolate(
+ distanceFromStart,
+ this.checkpoints[i].distanceFromStart,
+ this.checkpoints[i + 1].distanceFromStart,
+ this.checkpoints[i].altitude,
+ this.checkpoints[i + 1].altitude,
+ ),
+ remainingFuelOnBoard: Common.interpolate(
+ distanceFromStart,
+ this.checkpoints[i].distanceFromStart,
+ this.checkpoints[i + 1].distanceFromStart,
+ this.checkpoints[i].remainingFuelOnBoard,
+ this.checkpoints[i + 1].remainingFuelOnBoard,
+ ),
+ speed: Common.interpolate(
+ distanceFromStart,
+ this.checkpoints[i].distanceFromStart,
+ this.checkpoints[i + 1].distanceFromStart,
+ this.checkpoints[i].speed,
+ this.checkpoints[i + 1].speed,
+ ),
+ mach: Common.interpolate(
+ distanceFromStart,
+ this.checkpoints[i].distanceFromStart,
+ this.checkpoints[i + 1].distanceFromStart,
+ this.checkpoints[i].mach,
+ this.checkpoints[i + 1].mach,
+ ),
+ ...additionalProperties,
+ });
+
+ return;
+ }
+ }
+
+ this.checkpoints.push({
+ distanceFromStart,
+ secondsFromPresent: this.lastCheckpoint.secondsFromPresent,
+ altitude: this.lastCheckpoint.altitude,
+ remainingFuelOnBoard: this.lastCheckpoint.remainingFuelOnBoard,
+ speed: this.lastCheckpoint.speed,
+ mach: this.lastCheckpoint.mach,
+ ...additionalProperties,
+ });
+ }
+
+ addCheckpointAtDistanceFromStart(distanceFromStart: NauticalMiles, ...checkpoints: VerticalCheckpoint[]) {
+ if (distanceFromStart <= this.checkpoints[0].distanceFromStart) {
+ this.checkpoints.unshift(...checkpoints);
+
+ return;
+ }
+
+ for (let i = 0; i < this.checkpoints.length - 1; i++) {
+ if (distanceFromStart > this.checkpoints[i].distanceFromStart && distanceFromStart <= this.checkpoints[i + 1].distanceFromStart) {
+ this.checkpoints.splice(i + 1, 0, ...checkpoints);
+
+ return;
+ }
+ }
+
+ this.checkpoints.push(...checkpoints);
+ }
+
+ sortCheckpoints() {
+ this.checkpoints.sort((a, b) => a.distanceFromStart - b.distanceFromStart);
+ }
+
+ finalizeProfile() {
+ this.sortCheckpoints();
+
+ this.isReadyToDisplay = true;
+ }
+
+ computePredictionToFcuAltitude(fcuAltitude: Feet): PerformancePagePrediction | undefined {
+ const maxAltitude = this.checkpoints.reduce((currentMax, checkpoint) => Math.max(currentMax, checkpoint.altitude), 0);
+
+ if (fcuAltitude < this.checkpoints[0].altitude || fcuAltitude > maxAltitude) {
+ return undefined;
+ }
+
+ const distanceToFcuAltitude = this.interpolateFromCheckpoints(fcuAltitude, (checkpoint) => checkpoint.altitude, (checkpoint) => checkpoint.distanceFromStart);
+ const timeToFcuAltitude = this.interpolateTimeAtDistance(distanceToFcuAltitude);
+
+ return {
+ altitude: fcuAltitude,
+ distanceFromStart: distanceToFcuAltitude,
+ secondsFromPresent: timeToFcuAltitude,
+ };
+ }
+
+ getCheckpointsToDrawOnNd(): VerticalCheckpoint[] {
+ if (!this.isReadyToDisplay) {
+ return [];
+ }
+
+ const CHECKPOINTS_TO_DRAW_ON_ND = new Set([
+ VerticalCheckpointReason.TopOfClimb,
+ VerticalCheckpointReason.LevelOffForClimbConstraint,
+ VerticalCheckpointReason.ContinueClimb,
+ VerticalCheckpointReason.CrossingFcuAltitudeClimb,
+ VerticalCheckpointReason.TopOfDescent,
+ VerticalCheckpointReason.CrossingFcuAltitudeDescent,
+ VerticalCheckpointReason.LevelOffForDescentConstraint,
+ VerticalCheckpointReason.InterceptDescentProfileManaged,
+ VerticalCheckpointReason.InterceptDescentProfileSelected,
+ VerticalCheckpointReason.Decel,
+ VerticalCheckpointReason.Flaps1,
+ VerticalCheckpointReason.Flaps2,
+ VerticalCheckpointReason.Flaps3,
+ VerticalCheckpointReason.FlapsFull,
+ ]);
+
+ return this.checkpoints.filter((checkpoint) => CHECKPOINTS_TO_DRAW_ON_ND.has(checkpoint.reason));
+ }
+
+ getCheckpointsInMcdu(): VerticalCheckpoint[] {
+ if (!this.isReadyToDisplay) {
+ return [];
+ }
+
+ const CHECKPOINTS_TO_PUT_IN_MCDU = new Set([
+ VerticalCheckpointReason.TopOfClimb,
+ VerticalCheckpointReason.CrossingClimbSpeedLimit,
+
+ VerticalCheckpointReason.StepClimb,
+ VerticalCheckpointReason.StepDescent,
+
+ // Descent
+ VerticalCheckpointReason.TopOfDescent,
+ VerticalCheckpointReason.CrossingDescentSpeedLimit,
+
+ // Approach
+ VerticalCheckpointReason.Decel,
+ VerticalCheckpointReason.Flaps1,
+ VerticalCheckpointReason.Flaps2,
+ VerticalCheckpointReason.Flaps3,
+ VerticalCheckpointReason.FlapsFull,
+ ]);
+
+ return this.checkpoints.filter((checkpoint) => CHECKPOINTS_TO_PUT_IN_MCDU.has(checkpoint.reason));
+ }
+
+ addPresentPositionCheckpoint(presentPosition: LatLongAlt, remainingFuelOnBoard: number) {
+ this.checkpoints.push({
+ reason: VerticalCheckpointReason.PresentPosition,
+ distanceFromStart: this.distanceToPresentPosition,
+ secondsFromPresent: 0,
+ altitude: presentPosition.alt,
+ remainingFuelOnBoard,
+ speed: SimVar.GetSimVarValue('AIRSPEED INDICATED', 'knots'),
+ mach: SimVar.GetSimVarValue('AIRSPEED MACH', 'number'),
+ });
+ }
+
+ abstract resetAltitudeConstraints(): void;
+
+ getRemainingFuelAtDestination(): Pounds | null {
+ if (this.checkpoints.length < 1) {
+ return null;
+ }
+
+ if (this.lastCheckpoint.reason !== VerticalCheckpointReason.Landing) {
+ return null;
+ }
+
+ return this.lastCheckpoint.remainingFuelOnBoard;
+ }
+
+ getTimeToDestination(): Pounds | null {
+ if (this.checkpoints.length < 1) {
+ return null;
+ }
+
+ if (this.lastCheckpoint.reason !== VerticalCheckpointReason.Landing) {
+ return null;
+ }
+
+ return this.lastCheckpoint.secondsFromPresent;
+ }
+}
+
+type HasAtLeast = Pick & Partial>
+
+function findLastIndex(array: Array, predicate: (value: T, index: number, obj: T[]) => boolean): number {
+ let l = array.length;
+
+ while (l--) {
+ if (predicate(array[l], l, array)) {
+ return l;
+ }
+ }
+
+ return -1;
+}
diff --git a/src/fmgc/src/guidance/vnav/profile/NavGeometryProfile.ts b/src/fmgc/src/guidance/vnav/profile/NavGeometryProfile.ts
new file mode 100644
index 000000000000..824e0e0b9c50
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/profile/NavGeometryProfile.ts
@@ -0,0 +1,313 @@
+import { BaseGeometryProfile } from '@fmgc/guidance/vnav/profile/BaseGeometryProfile';
+import { ConstraintReader } from '@fmgc/guidance/vnav/ConstraintReader';
+import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions';
+import { XFLeg } from '@fmgc/guidance/lnav/legs/XF';
+import { VMLeg } from '@fmgc/guidance/lnav/legs/VM';
+import { Geometry } from '../../Geometry';
+import { AltitudeConstraint, AltitudeConstraintType, PathAngleConstraint, SpeedConstraint, SpeedConstraintType } from '../../lnav/legs';
+
+// TODO: Merge this with VerticalCheckpoint
+export interface VerticalWaypointPrediction {
+ waypointIndex: number,
+ distanceFromStart: NauticalMiles,
+ secondsFromPresent: Seconds,
+ altitude: Feet,
+ speed: Knots | Mach,
+ altitudeConstraint: AltitudeConstraint,
+ speedConstraint: SpeedConstraint,
+ isAltitudeConstraintMet: boolean,
+ isSpeedConstraintMet: boolean,
+ altError: number,
+ distanceToTopOfDescent: NauticalMiles | null,
+}
+
+export enum VerticalCheckpointReason {
+ Liftoff = 'Liftoff',
+ ThrustReductionAltitude = 'ThrustReductionAltitude',
+ AccelerationAltitude = 'AccelerationAltitude',
+ TopOfClimb = 'TopOfClimb',
+ AtmosphericConditions = 'AtmosphericConditions',
+ PresentPosition = 'PresentPosition',
+ LevelOffForClimbConstraint = 'LevelOffForClimbConstraint',
+ AltitudeConstraint = 'AltitudeConstraint',
+ ContinueClimb = 'ContinueClimb',
+ CrossingClimbSpeedLimit = 'CrossingClimbSpeedLimit',
+ SpeedConstraint = 'SpeedConstraint',
+ CrossingFcuAltitudeClimb = 'FcuAltitudeClimb',
+
+ // Cruise
+ StepClimb = 'StepClimb',
+ TopOfStepClimb = 'TopOfStepClimb',
+ StepDescent = 'StepDescent',
+ BottomOfStepDescent = 'BottomOfStepDescent', // I don't think this actually exists?
+
+ // Descent
+ CrossingFcuAltitudeDescent = 'FcuAltitudeDescent',
+ InterceptDescentProfileManaged = 'InterceptDescentProfileManaged',
+ InterceptDescentProfileSelected = 'InterceptDescentProfileSelected',
+ LevelOffForDescentConstraint = 'LevelOffForDescentConstraint',
+ ContinueDescent = 'ContinueDescent',
+ TopOfDescent = 'TopOfDescent',
+ CrossingDescentSpeedLimit = 'CrossingDescentSpeedLimit',
+ IdlePathAtmosphericConditions = 'IdlePathAtmosphericConditions',
+ IdlePathEnd = 'IdlePathEnd',
+ GeometricPathStart = 'GeometricPathStart',
+ GeometricPathConstraint = 'GeometricPathConstraint',
+ GeometricPathTooSteep = 'GeometricPathTooSteep',
+ GeometricPathEnd = 'GeometricPathEnd',
+
+ // Approach
+ Decel = 'Decel',
+ Flaps1 = 'Flaps1',
+ Flaps2 = 'Flaps2',
+ Flaps3 = 'Flaps3',
+ FlapsFull = 'FlapsFull',
+ Landing = 'Landing',
+}
+
+export interface VerticalCheckpoint {
+ reason: VerticalCheckpointReason,
+ distanceFromStart: NauticalMiles,
+ secondsFromPresent: Seconds,
+ altitude: Feet,
+ remainingFuelOnBoard: number,
+ speed: Knots,
+ mach: Mach,
+}
+
+export interface MaxAltitudeConstraint {
+ distanceFromStart: NauticalMiles,
+ maxAltitude: Feet,
+}
+
+export interface MaxSpeedConstraint {
+ distanceFromStart: NauticalMiles,
+ maxSpeed: Feet,
+}
+
+export interface DescentAltitudeConstraint {
+ distanceFromStart: NauticalMiles,
+ constraint: AltitudeConstraint,
+}
+
+export interface ApproachPathAngleConstraint {
+ distanceFromStart: NauticalMiles,
+ pathAngle: PathAngleConstraint,
+}
+
+export class NavGeometryProfile extends BaseGeometryProfile {
+ public waypointPredictions: Map = new Map();
+
+ constructor(
+ public geometry: Geometry,
+ private constraintReader: ConstraintReader,
+ private atmosphericConditions: AtmosphericConditions,
+ public waypointCount: number,
+ ) {
+ super();
+ }
+
+ override get maxAltitudeConstraints(): MaxAltitudeConstraint[] {
+ return this.constraintReader.climbAlitudeConstraints;
+ }
+
+ override get descentAltitudeConstraints(): DescentAltitudeConstraint[] {
+ return this.constraintReader.descentAltitudeConstraints;
+ }
+
+ override get maxClimbSpeedConstraints(): MaxSpeedConstraint[] {
+ return this.constraintReader.climbSpeedConstraints;
+ }
+
+ override get descentSpeedConstraints(): MaxSpeedConstraint[] {
+ return this.constraintReader.descentSpeedConstraints;
+ }
+
+ override get distanceToPresentPosition(): number {
+ return this.constraintReader.distanceToPresentPosition;
+ }
+
+ get totalFlightPlanDistance(): number {
+ return this.constraintReader.totalFlightPlanDistance;
+ }
+
+ get lastCheckpoint(): VerticalCheckpoint | null {
+ if (this.checkpoints.length < 1) {
+ return null;
+ }
+
+ return this.checkpoints[this.checkpoints.length - 1];
+ }
+
+ addCheckpointFromLast(checkpointBuilder: (lastCheckpoint: VerticalCheckpoint) => Partial) {
+ this.checkpoints.push({ ...this.lastCheckpoint, ...checkpointBuilder(this.lastCheckpoint) });
+ }
+
+ /**
+ * This is used to display predictions in the MCDU
+ */
+ private computePredictionsAtWaypoints(): Map {
+ const predictions = new Map();
+
+ if (!this.isReadyToDisplay) {
+ return predictions;
+ }
+
+ let totalDistance = 0;
+
+ const topOfDescent = this.findVerticalCheckpoint(VerticalCheckpointReason.TopOfDescent);
+
+ for (let i = 0; i < this.waypointCount; i++) {
+ const leg = this.geometry.legs.get(i);
+ if (!leg || leg.isNull) {
+ continue;
+ }
+
+ const inboundTransition = this.geometry.transitions.get(i - 1);
+ const outboundTransition = this.geometry.transitions.get(i);
+
+ const [inboundLength, legDistance, outboundLength] = Geometry.completeLegPathLengths(
+ leg, (inboundTransition?.isNull || !inboundTransition?.isComputed) ? null : inboundTransition, outboundTransition,
+ );
+
+ const correctedInboundLength = Number.isNaN(inboundLength) ? 0 : inboundLength;
+
+ const totalLegLength = legDistance + correctedInboundLength + outboundLength;
+
+ totalDistance += totalLegLength;
+
+ const { secondsFromPresent, altitude, speed, mach } = this.interpolateEverythingFromStart(totalDistance);
+
+ predictions.set(i, {
+ waypointIndex: i,
+ distanceFromStart: totalDistance,
+ secondsFromPresent,
+ altitude,
+ speed: this.atmosphericConditions.casOrMach(speed, mach, altitude),
+ altitudeConstraint: leg.metadata.altitudeConstraint,
+ isAltitudeConstraintMet: this.isAltitudeConstraintMet(altitude, leg.metadata.altitudeConstraint),
+ speedConstraint: leg.metadata.speedConstraint,
+ isSpeedConstraintMet: this.isSpeedConstraintMet(speed, leg.metadata.speedConstraint),
+ altError: this.computeAltError(altitude, leg.metadata.altitudeConstraint),
+ distanceToTopOfDescent: topOfDescent ? topOfDescent.distanceFromStart - totalDistance : null,
+ });
+
+ let distanceInDiscontinuity = 0;
+ const nextLeg = this.geometry.legs.get(i + 1);
+ const previousLeg = this.geometry.legs.get(i - 1);
+
+ if (leg instanceof XFLeg && leg.fix.endsInDiscontinuity && nextLeg instanceof XFLeg) {
+ distanceInDiscontinuity = Avionics.Utils.computeGreatCircleDistance(leg.fix.infos.coordinates, nextLeg.fix.infos.coordinates);
+ } else if (leg instanceof VMLeg && previousLeg instanceof XFLeg && nextLeg instanceof XFLeg) {
+ distanceInDiscontinuity = Avionics.Utils.computeGreatCircleDistance(previousLeg.fix.infos.coordinates, nextLeg.fix.infos.coordinates);
+ }
+
+ totalDistance += distanceInDiscontinuity;
+ }
+
+ return predictions;
+ }
+
+ override findDistancesToSpeedChanges(): NauticalMiles[] {
+ const result = [];
+
+ const checkpointBlacklist = [
+ VerticalCheckpointReason.AccelerationAltitude,
+ VerticalCheckpointReason.PresentPosition,
+ VerticalCheckpointReason.CrossingFcuAltitudeClimb,
+ VerticalCheckpointReason.CrossingFcuAltitudeDescent,
+ ];
+
+ for (let i = 0; i < this.checkpoints.length - 1; i++) {
+ const checkpoint = this.checkpoints[i];
+
+ if (checkpointBlacklist.includes(checkpoint.reason)) {
+ continue;
+ }
+
+ if (this.checkpoints[i + 1].speed - checkpoint.speed > 5) {
+ result.push(checkpoint.distanceFromStart);
+ }
+ }
+
+ return result;
+ }
+
+ private isAltitudeConstraintMet(altitude: Feet, constraint?: AltitudeConstraint): boolean {
+ if (!constraint) {
+ return true;
+ }
+
+ switch (constraint.type) {
+ case AltitudeConstraintType.at:
+ return Math.abs(altitude - constraint.altitude1) < 250;
+ case AltitudeConstraintType.atOrAbove:
+ return (altitude - constraint.altitude1) > -250;
+ case AltitudeConstraintType.atOrBelow:
+ return (altitude - constraint.altitude1) < 250;
+ case AltitudeConstraintType.range:
+ return (altitude - constraint.altitude2) > -250 && (altitude - constraint.altitude1) < 250;
+ default:
+ console.error('Invalid altitude constraint type');
+ return null;
+ }
+ }
+
+ private isSpeedConstraintMet(speed: Knots, constraint?: SpeedConstraint): boolean {
+ if (!constraint) {
+ return true;
+ }
+
+ switch (constraint.type) {
+ case SpeedConstraintType.at:
+ return Math.abs(speed - constraint.speed) < 5;
+ case SpeedConstraintType.atOrBelow:
+ return speed - constraint.speed < 5;
+ case SpeedConstraintType.atOrAbove:
+ return speed - constraint.speed > -5;
+ default:
+ console.error('Invalid speed constraint type');
+ return null;
+ }
+ }
+
+ private computeAltError(predictedAltitude: Feet, constraint?: AltitudeConstraint): number {
+ if (!constraint) {
+ return 0;
+ }
+
+ switch (constraint.type) {
+ case AltitudeConstraintType.at:
+ return predictedAltitude - constraint.altitude1;
+ case AltitudeConstraintType.atOrAbove:
+ return Math.min(predictedAltitude - constraint.altitude1, 0);
+ case AltitudeConstraintType.atOrBelow:
+ return Math.max(predictedAltitude - constraint.altitude1, 0);
+ case AltitudeConstraintType.range:
+ if (predictedAltitude >= constraint.altitude1) {
+ return predictedAltitude - constraint.altitude1;
+ } if (predictedAltitude <= constraint.altitude2) {
+ return predictedAltitude - constraint.altitude1;
+ }
+
+ return 0;
+ default:
+ console.error('Invalid altitude constraint type');
+ return 0;
+ }
+ }
+
+ override finalizeProfile(): void {
+ super.finalizeProfile();
+
+ this.waypointPredictions = this.computePredictionsAtWaypoints();
+ }
+
+ getDistanceFromStart(distanceFromEnd: NauticalMiles): NauticalMiles {
+ return this.constraintReader.totalFlightPlanDistance - distanceFromEnd;
+ }
+
+ override resetAltitudeConstraints() {
+ this.constraintReader.resetAltitudeConstraints();
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/profile/SelectedGeometryProfile.ts b/src/fmgc/src/guidance/vnav/profile/SelectedGeometryProfile.ts
new file mode 100644
index 000000000000..478dc9a1f8f2
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/profile/SelectedGeometryProfile.ts
@@ -0,0 +1,29 @@
+import { BaseGeometryProfile } from '@fmgc/guidance/vnav/profile/BaseGeometryProfile';
+import { DescentAltitudeConstraint, MaxAltitudeConstraint, MaxSpeedConstraint, VerticalCheckpoint, VerticalCheckpointReason } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+
+export class SelectedGeometryProfile extends BaseGeometryProfile {
+ public override maxAltitudeConstraints: MaxAltitudeConstraint[] = [];
+
+ public override descentAltitudeConstraints: DescentAltitudeConstraint[];
+
+ public override maxClimbSpeedConstraints: MaxSpeedConstraint[] = [];
+
+ public override descentSpeedConstraints: MaxSpeedConstraint[] = [];
+
+ public override distanceToPresentPosition: number = 0;
+
+ private checkpointsToShowAlongFlightPlan: Set = new Set([
+ VerticalCheckpointReason.CrossingFcuAltitudeClimb,
+ VerticalCheckpointReason.CrossingFcuAltitudeDescent,
+ VerticalCheckpointReason.CrossingClimbSpeedLimit,
+ ])
+
+ getCheckpointsToShowOnTrackLine(): VerticalCheckpoint[] {
+ return this.checkpoints.filter((checkpoint) => this.checkpointsToShowAlongFlightPlan.has(checkpoint.reason));
+ }
+
+ override resetAltitudeConstraints(): void {
+ this.maxAltitudeConstraints = [];
+ this.descentAltitudeConstraints = [];
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/profile/TemporaryCheckpointSequence.ts b/src/fmgc/src/guidance/vnav/profile/TemporaryCheckpointSequence.ts
new file mode 100644
index 000000000000..6c8192b924c2
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/profile/TemporaryCheckpointSequence.ts
@@ -0,0 +1,69 @@
+import { StepResults } from '@fmgc/guidance/vnav/Predictions';
+import { VerticalCheckpoint, VerticalCheckpointReason } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+
+export class TemporaryCheckpointSequence {
+ private checkpoints: VerticalCheckpoint[];
+
+ constructor(...checkpoints: VerticalCheckpoint[]) {
+ this.checkpoints = checkpoints;
+ }
+
+ at(index: number): VerticalCheckpoint {
+ return this.checkpoints[index];
+ }
+
+ get length(): number {
+ return this.checkpoints.length;
+ }
+
+ reset() {
+ this.checkpoints.splice(1);
+ }
+
+ undoLastStep() {
+ this.checkpoints.splice(this.checkpoints.length - 1);
+ }
+
+ addCheckpointFromStepBackwards(step: StepResults, reason: VerticalCheckpointReason) {
+ this.checkpoints.push({
+ reason,
+ distanceFromStart: this.lastCheckpoint.distanceFromStart - step.distanceTraveled,
+ altitude: step.initialAltitude,
+ secondsFromPresent: this.lastCheckpoint.secondsFromPresent - step.timeElapsed,
+ remainingFuelOnBoard: this.lastCheckpoint.remainingFuelOnBoard + step.fuelBurned,
+ speed: step.speed,
+ mach: this.lastCheckpoint.mach,
+ });
+ }
+
+ addCheckpointFromStep(step: StepResults, reason: VerticalCheckpointReason) {
+ this.checkpoints.push({
+ reason,
+ distanceFromStart: this.lastCheckpoint.distanceFromStart + step.distanceTraveled,
+ altitude: step.finalAltitude,
+ secondsFromPresent: this.lastCheckpoint.secondsFromPresent + step.timeElapsed,
+ remainingFuelOnBoard: this.lastCheckpoint.remainingFuelOnBoard - step.fuelBurned,
+ speed: step.speed,
+ mach: this.lastCheckpoint.mach,
+ });
+ }
+
+ get(includeStartingPoint = false) {
+ return this.checkpoints.slice(includeStartingPoint ? 0 : 1);
+ }
+
+ copyLastCheckpoint(newProperties: Partial) {
+ this.checkpoints.push({
+ ...this.lastCheckpoint,
+ ...newProperties,
+ });
+ }
+
+ get lastCheckpoint(): VerticalCheckpoint {
+ return this.checkpoints[this.checkpoints.length - 1];
+ }
+
+ push(...checkpoints: VerticalCheckpoint[]) {
+ this.checkpoints.push(...checkpoints);
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/takeoff/TakeoffPathBuilder.ts b/src/fmgc/src/guidance/vnav/takeoff/TakeoffPathBuilder.ts
new file mode 100644
index 000000000000..e4d507909116
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/takeoff/TakeoffPathBuilder.ts
@@ -0,0 +1,106 @@
+import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions';
+import { FlapConf } from '@fmgc/guidance/vnav/common';
+import { EngineModel } from '@fmgc/guidance/vnav/EngineModel';
+import { Predictions } from '@fmgc/guidance/vnav/Predictions';
+import { BaseGeometryProfile } from '@fmgc/guidance/vnav/profile/BaseGeometryProfile';
+import { VerticalCheckpointReason } from '@fmgc/guidance/vnav/profile/NavGeometryProfile';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+
+export class TakeoffPathBuilder {
+ constructor(private observer: VerticalProfileComputationParametersObserver, private atmosphericConditions: AtmosphericConditions) { }
+
+ buildTakeoffPath(profile: BaseGeometryProfile) {
+ this.addTakeoffRollCheckpoint(profile);
+ this.buildPathToThrustReductionAltitude(profile);
+ this.buildPathToAccelerationAltitude(profile);
+ }
+
+ private addTakeoffRollCheckpoint(profile: BaseGeometryProfile) {
+ const { originAirfieldElevation, v2Speed, fuelOnBoard, managedClimbSpeedMach } = this.observer.get();
+
+ profile.checkpoints.push({
+ reason: VerticalCheckpointReason.Liftoff,
+ distanceFromStart: 0.6,
+ secondsFromPresent: 20,
+ altitude: originAirfieldElevation,
+ remainingFuelOnBoard: fuelOnBoard,
+ speed: v2Speed + 10,
+ mach: managedClimbSpeedMach,
+ });
+ }
+
+ private buildPathToThrustReductionAltitude(profile: BaseGeometryProfile) {
+ const { perfFactor, zeroFuelWeight, v2Speed, tropoPause, thrustReductionAltitude, takeoffFlapsSetting, managedClimbSpeedMach } = this.observer.get();
+
+ const lastCheckpoint = profile.lastCheckpoint;
+
+ const startingAltitude = lastCheckpoint.altitude;
+ const predictedN1 = SimVar.GetSimVarValue('L:A32NX_AUTOTHRUST_THRUST_LIMIT_TOGA', 'Percent');
+ const speed = v2Speed + 10;
+
+ const { fuelBurned, distanceTraveled, timeElapsed } = Predictions.altitudeStep(
+ startingAltitude,
+ thrustReductionAltitude - startingAltitude,
+ speed,
+ this.atmosphericConditions.computeMachFromCas((thrustReductionAltitude + startingAltitude) / 2, speed),
+ predictedN1,
+ zeroFuelWeight,
+ profile.lastCheckpoint.remainingFuelOnBoard,
+ 0,
+ this.atmosphericConditions.isaDeviation,
+ tropoPause,
+ false,
+ takeoffFlapsSetting,
+ perfFactor,
+ );
+
+ profile.checkpoints.push({
+ reason: VerticalCheckpointReason.ThrustReductionAltitude,
+ distanceFromStart: profile.lastCheckpoint.distanceFromStart + distanceTraveled,
+ secondsFromPresent: profile.lastCheckpoint.secondsFromPresent + timeElapsed,
+ altitude: thrustReductionAltitude,
+ remainingFuelOnBoard: profile.lastCheckpoint.remainingFuelOnBoard - fuelBurned,
+ speed,
+ mach: managedClimbSpeedMach,
+ });
+ }
+
+ private buildPathToAccelerationAltitude(profile: BaseGeometryProfile) {
+ const lastCheckpoint = profile.lastCheckpoint;
+ const { accelerationAltitude, v2Speed, zeroFuelWeight, perfFactor, tropoPause, managedClimbSpeedMach } = this.observer.get();
+
+ const speed = v2Speed + 10;
+ const startingAltitude = lastCheckpoint.altitude;
+ const midwayAltitude = (startingAltitude + accelerationAltitude) / 2;
+
+ const v2PlusTenMach = this.atmosphericConditions.computeMachFromCas(midwayAltitude, speed);
+ const estimatedTat = this.atmosphericConditions.totalAirTemperatureFromMach(midwayAltitude, v2PlusTenMach);
+ const predictedN1 = EngineModel.tableInterpolation(EngineModel.maxClimbThrustTableLeap, estimatedTat, midwayAltitude);
+
+ const { fuelBurned, distanceTraveled, timeElapsed } = Predictions.altitudeStep(
+ startingAltitude,
+ accelerationAltitude - startingAltitude,
+ speed,
+ 1, // We never want to compute this in Mach, so we set the critical Mach to 1
+ predictedN1,
+ zeroFuelWeight,
+ lastCheckpoint.remainingFuelOnBoard,
+ 0,
+ this.atmosphericConditions.isaDeviation,
+ tropoPause,
+ false,
+ FlapConf.CLEAN,
+ perfFactor,
+ );
+
+ profile.checkpoints.push({
+ reason: VerticalCheckpointReason.AccelerationAltitude,
+ distanceFromStart: lastCheckpoint.distanceFromStart + distanceTraveled,
+ secondsFromPresent: lastCheckpoint.secondsFromPresent + timeElapsed,
+ altitude: accelerationAltitude,
+ remainingFuelOnBoard: lastCheckpoint.remainingFuelOnBoard - fuelBurned,
+ speed,
+ mach: managedClimbSpeedMach,
+ });
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/wind/AircraftHeadingProfile.ts b/src/fmgc/src/guidance/vnav/wind/AircraftHeadingProfile.ts
new file mode 100644
index 000000000000..cfc00ffd2328
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/wind/AircraftHeadingProfile.ts
@@ -0,0 +1,70 @@
+import { Geometry } from '@fmgc/guidance/Geometry';
+import { IFLeg } from '@fmgc/guidance/lnav/legs/IF';
+import { VnavConfig } from '@fmgc/guidance/vnav/VnavConfig';
+import { FlightPlanManager } from '@fmgc/wtsdk';
+
+interface CourseAtDistance {
+ distanceFromStart: NauticalMiles,
+ course: DegreesTrue,
+}
+
+export interface AircraftHeadingProfile {
+ get(distanceFromStart: NauticalMiles): DegreesTrue
+}
+
+export class NavHeadingProfile implements AircraftHeadingProfile {
+ private courses: CourseAtDistance[] = [];
+
+ constructor(private flightPlanManager: FlightPlanManager) { }
+
+ get(distanceFromStart: NauticalMiles): DegreesTrue {
+ if (distanceFromStart <= this.courses[0].distanceFromStart) {
+ return this.courses[0].course;
+ }
+
+ for (let i = 0; i < this.courses.length - 1; i++) {
+ if (distanceFromStart > this.courses[i].distanceFromStart && distanceFromStart <= this.courses[i + 1].distanceFromStart) {
+ return this.courses[i].course;
+ }
+ }
+
+ return this.courses[this.courses.length - 1].course;
+ }
+
+ updateGeometry(geometry: Geometry) {
+ this.courses = [];
+
+ const { legs, transitions } = geometry;
+
+ let distanceFromStart = 0;
+
+ for (let i = 0; i < this.flightPlanManager.getWaypointsCount(); i++) {
+ const leg = legs.get(i);
+
+ if (!leg || leg.isNull || leg instanceof IFLeg) {
+ continue;
+ }
+
+ const inboundTransition = transitions.get(i - 1);
+
+ const legDistance = Geometry.completeLegPathLengths(
+ leg, (inboundTransition?.isNull || !inboundTransition?.isComputed) ? null : inboundTransition, transitions.get(i),
+ ).reduce((sum, el) => sum + (!Number.isNaN(el) ? el : 0), 0);
+
+ distanceFromStart += legDistance;
+
+ if (!Number.isFinite(leg.outboundCourse)) {
+ if (VnavConfig.DEBUG_PROFILE) {
+ console.warn('[FMS/VNAV] Non-numerical outbound course encountered on leg: ', leg);
+ }
+
+ continue;
+ }
+
+ this.courses.push({
+ distanceFromStart,
+ course: leg.outboundCourse,
+ });
+ }
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/wind/ClimbWindProfile.ts b/src/fmgc/src/guidance/vnav/wind/ClimbWindProfile.ts
new file mode 100644
index 000000000000..871e4bbb1912
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/wind/ClimbWindProfile.ts
@@ -0,0 +1,71 @@
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { WindComponent, WindVector } from '@fmgc/guidance/vnav/wind';
+import { WindForecastInputs } from '@fmgc/guidance/vnav/wind/WindForecastInputs';
+import { WindObserver } from '@fmgc/guidance/vnav/wind/WindObserver';
+import { WindProfile } from '@fmgc/guidance/vnav/wind/WindProfile';
+import { FmgcFlightPhase } from '@shared/flightphase';
+
+export class ClimbWindProfile implements WindProfile {
+ constructor(
+ private parameterObserver: VerticalProfileComputationParametersObserver,
+ private inputs: WindForecastInputs,
+ private measurementDevice: WindObserver,
+ private aircraftDistanceFromStart: NauticalMiles,
+ ) { }
+
+ private interpolateByAltitude(altitude: Feet): WindVector {
+ if (this.inputs.climbWinds.length === 0) {
+ return WindVector.default();
+ }
+
+ if (altitude <= this.inputs.climbWinds[0].altitude) {
+ return this.inputs.climbWinds[0].vector;
+ }
+
+ for (let i = 0; i < this.inputs.climbWinds.length - 1; i++) {
+ if (altitude > this.inputs.climbWinds[i].altitude && altitude <= this.inputs.climbWinds[i + 1].altitude) {
+ const scaling = (altitude - this.inputs.climbWinds[i].altitude) / (this.inputs.climbWinds[i + 1].altitude - this.inputs.climbWinds[i].altitude);
+
+ return new WindVector(
+ (1 - scaling) * this.inputs.climbWinds[i].vector.direction + scaling * this.inputs.climbWinds[i + 1].vector.direction,
+ (1 - scaling) * this.inputs.climbWinds[i].vector.speed + scaling * this.inputs.climbWinds[i + 1].vector.speed,
+ );
+ }
+ }
+
+ return this.inputs.climbWinds[this.inputs.climbWinds.length - 1].vector;
+ }
+
+ getHeadwindComponent(distanceFromStart: NauticalMiles, altitude: Feet, planeHeading: DegreesTrue): WindComponent {
+ if (this.inputs.climbWinds.length === 0 && this.parameterObserver.get().flightPhase < FmgcFlightPhase.Takeoff) {
+ return this.inputs.tripWind;
+ }
+
+ const measurement = this.measurementDevice.get();
+ if (this.inputs.climbWinds.length === 0) {
+ return WindComponent.fromVector(measurement, planeHeading);
+ }
+
+ const forecast = this.interpolateByAltitude(altitude);
+ const distanceBetweenToAirplane = distanceFromStart - this.aircraftDistanceFromStart;
+
+ if (distanceBetweenToAirplane < 0) {
+ console.warn('[FMS/VNAV] Wind prediction made for point behind aircraft');
+ }
+
+ if (!measurement || distanceBetweenToAirplane > 0) {
+ return WindComponent.fromVector(forecast, planeHeading);
+ }
+
+ const scaling = Math.min(1, (distanceFromStart - this.aircraftDistanceFromStart) / 200);
+
+ return WindComponent.fromVector(this.interpolateVectors(measurement, forecast, scaling), planeHeading);
+ }
+
+ private interpolateVectors(vector1: WindVector, vector2: WindVector, scaling: number): WindVector {
+ return new WindVector(
+ (1 - scaling) * vector1.direction + scaling * vector2.direction,
+ (1 - scaling) * vector1.speed + scaling * vector2.speed,
+ );
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/wind/CruiseWindProfile.ts b/src/fmgc/src/guidance/vnav/wind/CruiseWindProfile.ts
new file mode 100644
index 000000000000..a078ad6669b4
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/wind/CruiseWindProfile.ts
@@ -0,0 +1,73 @@
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { WindComponent, WindVector } from '@fmgc/guidance/vnav/wind';
+import { WindForecastInputs } from '@fmgc/guidance/vnav/wind/WindForecastInputs';
+import { WindObserver } from '@fmgc/guidance/vnav/wind/WindObserver';
+import { WindProfile } from '@fmgc/guidance/vnav/wind/WindProfile';
+import { FmgcFlightPhase } from '@shared/flightphase';
+
+// TODO: Make this actually use cruise winds.
+// I have kept it like this for now, so I can inject it into the profile computations already and change the actual code when I'm ready.
+export class CruiseWindProfile implements WindProfile {
+ constructor(
+ private parameterObserver: VerticalProfileComputationParametersObserver,
+ private inputs: WindForecastInputs,
+ private measurementDevice: WindObserver,
+ private aircraftDistanceFromStart: NauticalMiles,
+ ) { }
+
+ private interpolateByAltitude(altitude: Feet): WindVector {
+ if (this.inputs.climbWinds.length === 0) {
+ return WindVector.default();
+ }
+
+ if (altitude <= this.inputs.climbWinds[0].altitude) {
+ return this.inputs.climbWinds[0].vector;
+ }
+
+ for (let i = 0; i < this.inputs.climbWinds.length - 1; i++) {
+ if (altitude > this.inputs.climbWinds[i].altitude && altitude <= this.inputs.climbWinds[i + 1].altitude) {
+ const scaling = (altitude - this.inputs.climbWinds[i].altitude) / (this.inputs.climbWinds[i + 1].altitude - this.inputs.climbWinds[i].altitude);
+
+ return new WindVector(
+ (1 - scaling) * this.inputs.climbWinds[i].vector.direction + scaling * this.inputs.climbWinds[i + 1].vector.direction,
+ (1 - scaling) * this.inputs.climbWinds[i].vector.speed + scaling * this.inputs.climbWinds[i + 1].vector.speed,
+ );
+ }
+ }
+
+ return this.inputs.climbWinds[this.inputs.climbWinds.length - 1].vector;
+ }
+
+ getHeadwindComponent(distanceFromStart: NauticalMiles, altitude: Feet, planeHeading: DegreesTrue): WindComponent {
+ if (this.inputs.climbWinds.length === 0 && this.parameterObserver.get().flightPhase < FmgcFlightPhase.Takeoff) {
+ return this.inputs.tripWind;
+ }
+
+ const measurement = this.measurementDevice.get();
+ if (this.inputs.climbWinds.length === 0) {
+ return WindComponent.fromVector(measurement, planeHeading);
+ }
+
+ const forecast = this.interpolateByAltitude(altitude);
+ const distanceBetweenToAirplane = distanceFromStart - this.aircraftDistanceFromStart;
+
+ if (distanceBetweenToAirplane < 0) {
+ console.warn('[FMS/VNAV] Wind prediction made for point behind aircraft');
+ }
+
+ if (!measurement || distanceBetweenToAirplane > 0) {
+ return WindComponent.fromVector(forecast, planeHeading);
+ }
+
+ const scaling = Math.min(1, (distanceFromStart - this.aircraftDistanceFromStart) / 200);
+
+ return WindComponent.fromVector(this.interpolateVectors(measurement, forecast, scaling), planeHeading);
+ }
+
+ private interpolateVectors(vector1: WindVector, vector2: WindVector, scaling: number): WindVector {
+ return new WindVector(
+ (1 - scaling) * vector1.direction + scaling * vector2.direction,
+ (1 - scaling) * vector1.speed + scaling * vector2.speed,
+ );
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/wind/DescentWindProfile.ts b/src/fmgc/src/guidance/vnav/wind/DescentWindProfile.ts
new file mode 100644
index 000000000000..8011640e2f45
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/wind/DescentWindProfile.ts
@@ -0,0 +1,71 @@
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { WindComponent, WindVector } from '@fmgc/guidance/vnav/wind';
+import { WindForecastInputs } from '@fmgc/guidance/vnav/wind/WindForecastInputs';
+import { WindObserver } from '@fmgc/guidance/vnav/wind/WindObserver';
+import { WindProfile } from '@fmgc/guidance/vnav/wind/WindProfile';
+import { FmgcFlightPhase } from '@shared/flightphase';
+
+export class DescentWindProfile implements WindProfile {
+ constructor(
+ private parameterObserver: VerticalProfileComputationParametersObserver,
+ private inputs: WindForecastInputs,
+ private measurementDevice: WindObserver,
+ private aircraftDistanceFromStart: NauticalMiles,
+ ) { }
+
+ private interpolateByAltitude(altitude: Feet): WindVector {
+ if (this.inputs.descentWinds.length === 0) {
+ return WindVector.default();
+ }
+
+ if (altitude <= this.inputs.descentWinds[0].altitude) {
+ return this.inputs.descentWinds[0].vector;
+ }
+
+ for (let i = 0; i < this.inputs.descentWinds.length - 1; i++) {
+ if (altitude > this.inputs.descentWinds[i].altitude && altitude <= this.inputs.descentWinds[i + 1].altitude) {
+ const scaling = (altitude - this.inputs.descentWinds[i].altitude) / (this.inputs.descentWinds[i + 1].altitude - this.inputs.descentWinds[i].altitude);
+
+ return new WindVector(
+ (1 - scaling) * this.inputs.descentWinds[i].vector.direction + scaling * this.inputs.descentWinds[i + 1].vector.direction,
+ (1 - scaling) * this.inputs.descentWinds[i].vector.speed + scaling * this.inputs.descentWinds[i + 1].vector.speed,
+ );
+ }
+ }
+
+ return this.inputs.descentWinds[this.inputs.descentWinds.length - 1].vector;
+ }
+
+ getHeadwindComponent(distanceFromStart: NauticalMiles, altitude: Feet, planeHeading: DegreesTrue): WindComponent {
+ if (this.inputs.descentWinds.length === 0 && this.parameterObserver.get().flightPhase < FmgcFlightPhase.Takeoff) {
+ return this.inputs.tripWind;
+ }
+
+ const measurement = this.measurementDevice.get();
+ if (this.inputs.descentWinds.length === 0) {
+ return WindComponent.fromVector(measurement, planeHeading);
+ }
+
+ const forecast = this.interpolateByAltitude(altitude);
+ const distanceBetweenToAirplane = distanceFromStart - this.aircraftDistanceFromStart;
+
+ if (distanceBetweenToAirplane < 0) {
+ console.warn('[FMS/VNAV] Wind prediction made for point behind aircraft');
+ }
+
+ if (!measurement || distanceBetweenToAirplane > 0) {
+ return WindComponent.fromVector(forecast, planeHeading);
+ }
+
+ const scaling = Math.min(1, (distanceFromStart - this.aircraftDistanceFromStart) / 200);
+
+ return WindComponent.fromVector(this.interpolateVectors(measurement, forecast, scaling), planeHeading);
+ }
+
+ private interpolateVectors(vector1: WindVector, vector2: WindVector, scaling: number): WindVector {
+ return new WindVector(
+ (1 - scaling) * vector1.direction + scaling * vector2.direction,
+ (1 - scaling) * vector1.speed + scaling * vector2.speed,
+ );
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/wind/HeadwindProfile.ts b/src/fmgc/src/guidance/vnav/wind/HeadwindProfile.ts
new file mode 100644
index 000000000000..67bf5e1991cb
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/wind/HeadwindProfile.ts
@@ -0,0 +1,21 @@
+import { WindComponent } from '@fmgc/guidance/vnav/wind';
+import { AircraftHeadingProfile } from '@fmgc/guidance/vnav/wind/AircraftHeadingProfile';
+import { WindProfile } from '@fmgc/guidance/vnav/wind/WindProfile';
+
+export class HeadwindProfile {
+ constructor(
+ private windProfile: WindProfile,
+ private headingProfile: AircraftHeadingProfile,
+ ) { }
+
+ /**
+ * Returns the predicted headwind component at a given distanceFromStart and altitude
+ * @param distanceFromStart
+ * @param altitude
+ * @returns
+ */
+ getHeadwindComponent(distanceFromStart: NauticalMiles, altitude: Feet): WindComponent {
+ const heading = this.headingProfile.get(distanceFromStart);
+ return this.windProfile.getHeadwindComponent(distanceFromStart, altitude, heading);
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/wind/WindForecastInputObserver.ts b/src/fmgc/src/guidance/vnav/wind/WindForecastInputObserver.ts
new file mode 100644
index 000000000000..a30f70454fd0
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/wind/WindForecastInputObserver.ts
@@ -0,0 +1,60 @@
+import { Fmgc } from '@fmgc/guidance/GuidanceController';
+import { WindComponent, WindVector, WindVectorAtAltitude } from '@fmgc/guidance/vnav/wind';
+import { FmcWinds, FmcWindEntry } from '@fmgc/guidance/vnav/wind/types';
+import { WindForecastInputs } from '@fmgc/guidance/vnav/wind/WindForecastInputs';
+
+export class WindForecastInputObserver {
+ private inputs: WindForecastInputs
+
+ constructor(private fmgc: Fmgc) {
+ this.inputs = {
+ tripWind: new WindComponent(0),
+ climbWinds: [],
+ cruiseWindsByWaypoint: new Map(),
+ descentWinds: [],
+ destinationWind: new WindVector(0, 0),
+ };
+
+ this.update();
+ }
+
+ update() {
+ this.inputs.tripWind = new WindComponent(this.fmgc.getTripWind() ?? 0);
+ this.parseFmcWinds(this.fmgc.getWinds());
+
+ const { direction, speed } = this.fmgc.getApproachWind();
+ this.inputs.destinationWind = new WindVector(direction, speed);
+ }
+
+ get(): WindForecastInputs {
+ return this.inputs;
+ }
+
+ get tripWind(): WindComponent {
+ return this.inputs.tripWind;
+ }
+
+ get climbWinds(): WindVectorAtAltitude[] {
+ return this.inputs.climbWinds;
+ }
+
+ get cruiseWindsByWaypoint(): Map {
+ return this.inputs.cruiseWindsByWaypoint;
+ }
+
+ get descentWinds(): WindVectorAtAltitude[] {
+ return this.inputs.descentWinds;
+ }
+
+ get destinationWind(): WindVector {
+ return this.inputs.destinationWind;
+ }
+
+ private parseFmcWinds(fmcWinds: FmcWinds) {
+ const parseFmcWindEntry = ({ direction, speed, altitude }: FmcWindEntry): WindVectorAtAltitude => ({ altitude, vector: new WindVector(direction, speed) });
+
+ this.inputs.climbWinds = fmcWinds.climb.map(parseFmcWindEntry);
+ // TODO: Cruise winds
+ this.inputs.descentWinds = fmcWinds.des.map(parseFmcWindEntry);
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/wind/WindForecastInputs.ts b/src/fmgc/src/guidance/vnav/wind/WindForecastInputs.ts
new file mode 100644
index 000000000000..d0a741fdf535
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/wind/WindForecastInputs.ts
@@ -0,0 +1,9 @@
+import { WindComponent, WindVector, WindVectorAtAltitude } from '@fmgc/guidance/vnav/wind';
+
+export interface WindForecastInputs {
+ tripWind: WindComponent;
+ climbWinds: WindVectorAtAltitude[],
+ cruiseWindsByWaypoint: Map,
+ descentWinds: WindVectorAtAltitude[],
+ destinationWind: WindVector;
+}
diff --git a/src/fmgc/src/guidance/vnav/wind/WindObserver.ts b/src/fmgc/src/guidance/vnav/wind/WindObserver.ts
new file mode 100644
index 000000000000..ed912bce0380
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/wind/WindObserver.ts
@@ -0,0 +1,19 @@
+import { WindVector } from '@fmgc/guidance/vnav/wind';
+import { Arinc429Word } from '@shared/arinc429';
+
+export class WindObserver {
+ constructor(private irsIndex: number) {
+
+ }
+
+ get(): WindVector | null {
+ const windDirection = Arinc429Word.fromSimVarValue(`L:A32NX_ADIRS_IR_${this.irsIndex}_WIND_DIRECTION`);
+ const windVelocity = Arinc429Word.fromSimVarValue(`L:A32NX_ADIRS_IR_${this.irsIndex}_WIND_VELOCITY`);
+
+ if (!windDirection.isNormalOperation() || !windVelocity.isNormalOperation()) {
+ return null;
+ }
+
+ return new WindVector(windDirection.value, windVelocity.value);
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/wind/WindProfile.ts b/src/fmgc/src/guidance/vnav/wind/WindProfile.ts
new file mode 100644
index 000000000000..d7565eb498cf
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/wind/WindProfile.ts
@@ -0,0 +1,5 @@
+import { WindComponent } from '@fmgc/guidance/vnav/wind';
+
+export interface WindProfile {
+ getHeadwindComponent(distanceFromStart: NauticalMiles, altitude: Feet, planeHeading: DegreesTrue): WindComponent
+}
diff --git a/src/fmgc/src/guidance/vnav/wind/WindProfileFactory.ts b/src/fmgc/src/guidance/vnav/wind/WindProfileFactory.ts
new file mode 100644
index 000000000000..50e14d2e4da8
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/wind/WindProfileFactory.ts
@@ -0,0 +1,55 @@
+import { Fmgc } from '@fmgc/guidance/GuidanceController';
+import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters';
+import { ClimbWindProfile } from '@fmgc/guidance/vnav/wind/ClimbWindProfile';
+import { CruiseWindProfile } from '@fmgc/guidance/vnav/wind/CruiseWindProfile';
+import { DescentWindProfile } from '@fmgc/guidance/vnav/wind/DescentWindProfile';
+import { WindForecastInputObserver } from '@fmgc/guidance/vnav/wind/WindForecastInputObserver';
+import { WindObserver } from '@fmgc/guidance/vnav/wind/WindObserver';
+
+export class WindProfileFactory {
+ private windObserver: WindObserver;
+
+ private windInputObserver: WindForecastInputObserver;
+
+ private aircraftDistanceFromStart: NauticalMiles;
+
+ constructor(private parameterObserver: VerticalProfileComputationParametersObserver, fmgc: Fmgc, fmgcSide: number) {
+ this.windObserver = new WindObserver(fmgcSide);
+ this.windInputObserver = new WindForecastInputObserver(fmgc);
+ }
+
+ updateFmgcInputs() {
+ this.windInputObserver.update();
+ }
+
+ updateAircraftDistanceFromStart(distanceFromStart: NauticalMiles) {
+ this.aircraftDistanceFromStart = distanceFromStart;
+ }
+
+ getClimbWinds(): ClimbWindProfile {
+ return new ClimbWindProfile(
+ this.parameterObserver,
+ this.windInputObserver.get(),
+ this.windObserver,
+ this.aircraftDistanceFromStart,
+ );
+ }
+
+ getCruiseWinds(): CruiseWindProfile {
+ return new CruiseWindProfile(
+ this.parameterObserver,
+ this.windInputObserver.get(),
+ this.windObserver,
+ this.aircraftDistanceFromStart,
+ );
+ }
+
+ getDescentWinds(): DescentWindProfile {
+ return new DescentWindProfile(
+ this.parameterObserver,
+ this.windInputObserver.get(),
+ this.windObserver,
+ this.aircraftDistanceFromStart,
+ );
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/wind/index.ts b/src/fmgc/src/guidance/vnav/wind/index.ts
new file mode 100644
index 000000000000..f2b1b0f7bb3a
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/wind/index.ts
@@ -0,0 +1,50 @@
+import { MathUtils } from '@shared/MathUtils';
+
+export class WindVector {
+ constructor(public direction: DegreesTrue, public speed: Knots) {
+ Avionics.Utils.clampAngle(direction);
+
+ if (speed < 0) {
+ this.flipDirection();
+ this.speed *= -1;
+ }
+ }
+
+ private flipDirection() {
+ if (this.direction < 180) {
+ this.direction += 180;
+ }
+
+ this.direction -= 180;
+ }
+
+ static default(): WindVector {
+ return new WindVector(0, 0);
+ }
+}
+
+export interface WindVectorAtAltitude {
+ vector: WindVector,
+ altitude: Feet,
+}
+
+export interface WindMeasurement {
+ wind: WindVectorAtAltitude,
+ distanceFromStart: NauticalMiles
+}
+
+export class WindComponent {
+ /**
+ *
+ * @param value +ve for a tailwind, -ve for headwind
+ */
+ constructor(public value: number) { }
+
+ static fromVector(vector: WindVector, planeHeading: DegreesTrue): WindComponent {
+ return new WindComponent(vector.speed * Math.cos(MathUtils.DEGREES_TO_RADIANS * Avionics.Utils.diffAngle(vector.direction, planeHeading)));
+ }
+
+ static zero(): WindComponent {
+ return new WindComponent(0);
+ }
+}
diff --git a/src/fmgc/src/guidance/vnav/wind/types.ts b/src/fmgc/src/guidance/vnav/wind/types.ts
new file mode 100644
index 000000000000..feb176def09b
--- /dev/null
+++ b/src/fmgc/src/guidance/vnav/wind/types.ts
@@ -0,0 +1,15 @@
+// These are how the winds are entered in the MCDU. The are saved in FMCMainDisplay.
+// They should probably be typed properly using d.ts files, but this is an intermediate step until the whole MCDU gets typed at some point
+export interface FmcWindVector {
+ direction: number,
+ speed: number,
+}
+
+export type FmcWindEntry = FmcWindVector & { altitude: number };
+
+export interface FmcWinds {
+ climb: FmcWindVector[],
+ cruise: FmcWindVector[],
+ des: FmcWindVector[],
+ alternate: null,
+}
diff --git a/src/fmgc/src/index.ts b/src/fmgc/src/index.ts
index df88a49767e2..c0721c33e048 100644
--- a/src/fmgc/src/index.ts
+++ b/src/fmgc/src/index.ts
@@ -7,7 +7,7 @@ import { ManagedFlightPlan } from './flightplanning/ManagedFlightPlan';
import { GuidanceController } from './guidance/GuidanceController';
import { NavRadioManager } from './radionav/NavRadioManager';
import { EfisSymbols } from './efis/EfisSymbols';
-import { DescentBuilder } from './guidance/vnav/descent/DescentBuilder';
+import { DescentPathBuilder } from './guidance/vnav/descent/DescentPathBuilder';
import { DecelPathBuilder } from './guidance/vnav/descent/DecelPathBuilder';
import { VerticalFlightPlanBuilder } from './guidance/vnav/verticalFlightPlan/VerticalFlightPlanBuilder';
import { initComponents, updateComponents, recallMessageById } from './components';
@@ -33,7 +33,7 @@ export {
updateFmgcLoop,
recallMessageById,
EfisSymbols,
- DescentBuilder,
+ DescentPathBuilder,
DecelPathBuilder,
VerticalFlightPlanBuilder,
WaypointBuilder,
diff --git a/src/fmgc/src/utils/TimeUtils.ts b/src/fmgc/src/utils/TimeUtils.ts
new file mode 100644
index 000000000000..a41cf9e9a6c6
--- /dev/null
+++ b/src/fmgc/src/utils/TimeUtils.ts
@@ -0,0 +1,30 @@
+export namespace TimeUtils {
+
+ /**
+ * Formats a time since 00:00 s into an HH:MM format
+ *
+ * @param a time in seconds since 00:00
+ * @param b offset in seconds
+ *
+ * @private
+ */
+ export function addSeconds(a: Seconds, b: Seconds): Seconds {
+ return (a + b) % (3600 * 24);
+ }
+
+ /**
+ * Formats a time since 00:00 s into an HH:MM format
+ *
+ * @param time in seconds since 00:00
+ * @param colon whether to include a colon or not
+ *
+ * @private
+ */
+ export function formatSeconds(time: Seconds, colon = true): string {
+ const hh = Math.floor(time / 3600);
+ const mm = Math.floor((time % 3600) / 60);
+
+ return `${hh.toString().padStart(2, '0')}${colon ? ':' : ''}${mm.toString().padStart(2, '0')}`;
+ }
+
+}
diff --git a/src/instruments/src/Common/definitions.scss b/src/instruments/src/Common/definitions.scss
index 350d56de1b21..ca1135bb2282 100644
--- a/src/instruments/src/Common/definitions.scss
+++ b/src/instruments/src/Common/definitions.scss
@@ -14,6 +14,7 @@ $display-amber: #e68000;
$display-cyan: #00ffff;
$display-green: #00ff00;
$display-magenta: #ff94ff;
+$display-dark-magenta: #ff38d8;
$display-red: #ff0000;
$display-yellow: #ffff00;
diff --git a/src/instruments/src/ND/elements/FlightPlan.tsx b/src/instruments/src/ND/elements/FlightPlan.tsx
index 101330871379..59763d9ed766 100644
--- a/src/instruments/src/ND/elements/FlightPlan.tsx
+++ b/src/instruments/src/ND/elements/FlightPlan.tsx
@@ -35,12 +35,10 @@ export const FlightPlan: FC = memo(({ x = 0, y = 0, side, range
return null;
}
- const constraintFlags = NdSymbolTypeFlags.ConstraintMet | NdSymbolTypeFlags.ConstraintMissed | NdSymbolTypeFlags.ConstraintUnknown;
-
return (
{ /* constraint circles need to be drawn under the flight path */ }
- {symbols.filter((symbol) => (symbol.type & constraintFlags) > 0).map((symbol) => {
+ {symbols.filter((symbol) => (symbol.type & NdSymbolTypeFlags.Constraint) > 0).map((symbol) => {
const position = mapParams.coordinatesToXYy(symbol.location);
return (
@@ -66,6 +64,10 @@ export const FlightPlan: FC = memo(({ x = 0, y = 0, side, range
))}
{symbols.map((symbol) => {
+ if (!symbol.location) {
+ return false;
+ }
+
const position = mapParams.coordinatesToXYy(symbol.location);
let endPosition;
@@ -256,7 +258,7 @@ interface SymbolMarkerProps {
ndRange: number,
}
-const SymbolMarker: FC = memo(({ ident, x, y, endX, endY, arcRadius, arcSweep, type, constraints, length, direction, radials, radii, mapParams, ndRange }) => {
+export const SymbolMarker: FC = memo(({ ident, x, y, endX, endY, arcRadius, arcSweep, type, constraints, length, direction, radials, radii, mapParams, ndRange }) => {
let colour = 'White';
let shadow = true;
// todo airport as well if in flightplan
@@ -314,7 +316,7 @@ const SymbolMarker: FC = memo(({ ident, x, y, endX, endY, arc
if (constraints) {
let constraintY = 17;
elements.push(...constraints.map((t) => (
- {t}
+ {t}
)));
}
@@ -386,9 +388,9 @@ const SymbolMarker: FC = memo(({ ident, x, y, endX, endY, arc
showIdent = false;
elements.push(
<>
-
+
-
+
>,
);
} else if (type & (NdSymbolTypeFlags.PwpCdaFlap1)) {
@@ -398,7 +400,7 @@ const SymbolMarker: FC = memo(({ ident, x, y, endX, endY, arc
- 1
+ 1
>,
);
} else if (type & (NdSymbolTypeFlags.PwpCdaFlap2)) {
@@ -408,7 +410,7 @@ const SymbolMarker: FC = memo(({ ident, x, y, endX, endY, arc
- 2
+ 2
>,
);
} else if (type & (NdSymbolTypeFlags.PwpDecel)) {
@@ -416,11 +418,61 @@ const SymbolMarker: FC = memo(({ ident, x, y, endX, endY, arc
elements.push(
<>
-
+
+
+ D
+ >,
+ );
+ } else if (type & (NdSymbolTypeFlags.PwpClimbLevelOff)) {
+ showIdent = false;
+ elements.push(
+ <>
+
+
+
+ >,
+ );
+ } else if (type & (NdSymbolTypeFlags.PwpDescentLevelOff)) {
+ showIdent = false;
+ elements.push(
+ <>
+
- D
+
>,
);
+ } else if (type & (NdSymbolTypeFlags.PwpStartOfClimb)) {
+ showIdent = false;
+ elements.push(
+ <>
+
+
+
+ >,
+ );
+ } else if (type & (NdSymbolTypeFlags.PwpInterceptProfile)) {
+ showIdent = false;
+ elements.push(
+ <>
+
+
+
+ >,
+ );
+ } else if (type & (NdSymbolTypeFlags.PwpTimeMarker)) {
+ colour = 'Green';
+ showIdent = true;
+ elements.push(
+ <>
+
+
+ >,
+ );
+ } else if (type & (NdSymbolTypeFlags.PwpSpeedChange)) {
+ showIdent = false;
+ elements.push(
+ ,
+ );
}
if (showIdent) {
@@ -444,32 +496,12 @@ interface ConstraintMarkerProps {
type: NdSymbolTypeFlags,
}
-const ConstraintMarker: FC = memo(({ x, y, type }) => {
- if (type & NdSymbolTypeFlags.ConstraintMet) {
- return (
-
-
-
-
- );
- }
-
- if (type & NdSymbolTypeFlags.ConstraintMissed) {
- return (
-
-
-
-
- );
- }
-
- return (
-
-
-
-
- );
-});
+export const ConstraintMarker: FC = memo(({ x, y, type }) => (
+
+
+
+
+));
export type DebugLegProps = {
leg: TLeg,
@@ -572,3 +604,15 @@ const DebugHXLeg: FC> = ({ leg, mapParams }
>
);
};
+
+const typeFlagToColor = (typeFlag: NdSymbolTypeFlags) => {
+ if (typeFlag & NdSymbolTypeFlags.CyanColor) {
+ return 'Cyan';
+ } if (typeFlag & NdSymbolTypeFlags.MagentaColor) {
+ return 'Magenta';
+ } if (typeFlag & NdSymbolTypeFlags.AmberColor) {
+ return 'Amber';
+ }
+
+ return 'White';
+};
diff --git a/src/instruments/src/ND/elements/TrackLine.tsx b/src/instruments/src/ND/elements/TrackLine.tsx
index 64ff296cf94e..ff166c1887c0 100644
--- a/src/instruments/src/ND/elements/TrackLine.tsx
+++ b/src/instruments/src/ND/elements/TrackLine.tsx
@@ -1,13 +1,40 @@
import { MathUtils } from '@shared/MathUtils';
-import React, { memo } from 'react';
+import { NdSymbol } from '@shared/NavigationDisplay';
+import React, { memo, useEffect, useState } from 'react';
+import { SymbolMarker } from './FlightPlan';
+import { MapParameters } from '../utils/MapParameters';
-export const TrackLine: React.FC<{ x: number, y: number, heading: number, track:number}> = memo(({ x, y, heading, track }) => {
+interface TrackLineProps {
+ x: number,
+ y: number,
+ heading: number,
+ track: number,
+ groundSpeed: Knots,
+ mapParams: MapParameters,
+ symbols: NdSymbol[]
+}
+
+export const TrackLine: React.FC = memo(({ x, y, heading, track, mapParams, groundSpeed, symbols }) => {
const rotate = MathUtils.diffAngle(heading, track);
+ const [lastUpdateTime, setLastUpdateTime] = useState(Date.now());
+
+ useEffect(() => {
+ setLastUpdateTime(Date.now());
+ }, [symbols]);
return (
+
+ {symbols.map((symbol) => {
+ if (!symbol.distanceFromAirplane) {
+ return false;
+ }
+
+ const dy = (symbol.distanceFromAirplane - groundSpeed * (Date.now() - lastUpdateTime) / 1000 / 60 / 60) * mapParams.nmToPx;
+ return ;
+ })}
);
});
diff --git a/src/instruments/src/ND/pages/ArcMode.tsx b/src/instruments/src/ND/pages/ArcMode.tsx
index 61c50e464647..cfa61a4ee0a1 100644
--- a/src/instruments/src/ND/pages/ArcMode.tsx
+++ b/src/instruments/src/ND/pages/ArcMode.tsx
@@ -80,7 +80,7 @@ export const ArcMode: React.FC = ({ symbols, adirsAlign, rangeSett
|| fmaLatMode === LateralMode.HDG
|| fmaLatMode === LateralMode.TRACK)
&& !isArmed(armedLateralBitmask, ArmedLateralMode.NAV)) && (
-
+
)}
diff --git a/src/instruments/src/ND/pages/RoseMode.tsx b/src/instruments/src/ND/pages/RoseMode.tsx
index 7e43fc2fd4df..bb96c501ccfd 100644
--- a/src/instruments/src/ND/pages/RoseMode.tsx
+++ b/src/instruments/src/ND/pages/RoseMode.tsx
@@ -79,7 +79,7 @@ export const RoseMode: FC = ({ symbols, adirsAlign, rangeSetting,
{ ((fmaLatMode === LateralMode.NONE || fmaLatMode === LateralMode.HDG || fmaLatMode === LateralMode.TRACK)
&& !isArmed(armedLateralBitmask, ArmedLateralMode.NAV)) && (
-
+
)}
)}
diff --git a/src/instruments/src/ND/styles.scss b/src/instruments/src/ND/styles.scss
index a545f0acda78..799208bbc1ad 100644
--- a/src/instruments/src/ND/styles.scss
+++ b/src/instruments/src/ND/styles.scss
@@ -37,6 +37,12 @@
stroke: $display-magenta;
}
+// Used for the speed change dot
+.Magenta.Fill {
+ fill: $display-dark-magenta;
+ stroke: none;
+}
+
.Magenta text,
text.Magenta {
fill: $display-magenta;
@@ -48,6 +54,11 @@ text.Magenta {
stroke: $display-cyan;
}
+.Cyan.Fill {
+ fill: $display-cyan;
+ stroke: none;
+}
+
.Cyan text,
text.Cyan {
fill: $display-cyan;
diff --git a/src/instruments/src/PFD/FMA.tsx b/src/instruments/src/PFD/FMA.tsx
index a320c6a4f845..4a7597594a70 100644
--- a/src/instruments/src/PFD/FMA.tsx
+++ b/src/instruments/src/PFD/FMA.tsx
@@ -47,6 +47,8 @@ export class FMA extends DisplayComponent<{ bus: EventBus, isAttExcessive: Subsc
private setHoldSpeed = false;
+ private tdReached = false;
+
private tcasRaInhibited = Subject.create(false);
private trkFpaDeselected = Subject.create(false);
@@ -61,7 +63,7 @@ export class FMA extends DisplayComponent<{ bus: EventBus, isAttExcessive: Subsc
const sharedModeActive = this.activeLateralMode === 32 || this.activeLateralMode === 33
|| this.activeLateralMode === 34 || (this.activeLateralMode === 20 && this.activeVerticalMode === 24);
const BC3Message = getBC3Message(this.props.isAttExcessive.get(), this.armedVerticalModeSub.get(),
- this.setHoldSpeed, this.trkFpaDeselected.get(), this.tcasRaInhibited.get())[0] !== null;
+ this.setHoldSpeed, this.trkFpaDeselected.get(), this.tcasRaInhibited.get(), this.tdReached)[0] !== null;
const engineMessage = this.athrModeMessage;
const AB3Message = (this.machPreselVal !== -1
@@ -135,6 +137,11 @@ export class FMA extends DisplayComponent<{ bus: EventBus, isAttExcessive: Subsc
this.trkFpaDeselected.set(trk);
this.handleFMABorders();
});
+
+ sub.on('tdReached').whenChanged().handle((tdr) => {
+ this.tdReached = tdr;
+ this.handleFMABorders();
+ });
}
render(): VNode {
@@ -1156,7 +1163,7 @@ class BC1Cell extends ShowForSecondsComponent {
}
}
-const getBC3Message = (isAttExcessive: boolean, armedVerticalMode: number, setHoldSpeed: boolean, trkFpaDeselectedTCAS: boolean, tcasRaInhibited: boolean) => {
+const getBC3Message = (isAttExcessive: boolean, armedVerticalMode: number, setHoldSpeed: boolean, trkFpaDeselectedTCAS: boolean, tcasRaInhibited: boolean, tdReached: boolean) => {
const armedVerticalBitmask = armedVerticalMode;
const TCASArmed = (armedVerticalBitmask >> 6) & 1;
@@ -1187,7 +1194,7 @@ const getBC3Message = (isAttExcessive: boolean, armedVerticalMode: number, setHo
} else if (false) {
text = 'SET GREEN DOT SPEED';
className = 'White';
- } else if (false) {
+ } else if (tdReached) {
text = 'T/D REACHED';
className = 'White';
} else if (false) {
@@ -1233,8 +1240,10 @@ class BC3Cell extends DisplayComponent<{ isAttExcessive: Subscribable,
private trkFpaDeselected = false;
+ private tdReached = false;
+
private fillBC3Cell() {
- const [text, className] = getBC3Message(this.isAttExcessive, this.armedVerticalMode, this.setHoldSpeed, this.trkFpaDeselected, this.tcasRaInhibited);
+ const [text, className] = getBC3Message(this.isAttExcessive, this.armedVerticalMode, this.setHoldSpeed, this.trkFpaDeselected, this.tcasRaInhibited, this.tdReached);
this.classNameSub.set(`FontMedium MiddleAlign ${className}`);
if (text !== null) {
this.bc3Cell.instance.innerHTML = text;
@@ -1272,6 +1281,11 @@ class BC3Cell extends DisplayComponent<{ isAttExcessive: Subscribable,
this.trkFpaDeselected = trk;
this.fillBC3Cell();
});
+
+ sub.on('tdReached').whenChanged().handle((shs) => {
+ this.tdReached = shs;
+ this.fillBC3Cell();
+ });
}
render(): VNode {
diff --git a/src/instruments/src/PFD/LinearDeviationIndicator.tsx b/src/instruments/src/PFD/LinearDeviationIndicator.tsx
new file mode 100644
index 000000000000..72ffa7e7bfdb
--- /dev/null
+++ b/src/instruments/src/PFD/LinearDeviationIndicator.tsx
@@ -0,0 +1,139 @@
+import { DisplayComponent, EventBus, FSComponent, NodeReference, Subject, VNode } from 'msfssdk';
+import { Arinc429Word } from '@shared/arinc429';
+import { PFDSimvars } from './shared/PFDSimvarPublisher';
+
+ type LinearDeviationIndicatorProps = {
+ bus: EventBus,
+ }
+
+export class LinearDeviationIndicator extends DisplayComponent {
+ private lastIsActive: boolean = false;
+
+ private component = FSComponent.createRef();
+
+ private upperLinearDeviationReadout = FSComponent.createRef();
+
+ private lowerLinearDeviationReadout = FSComponent.createRef();
+
+ private linearDeviationDot = FSComponent.createRef();
+
+ private linearDeviationDotUpperHalf = FSComponent.createRef();
+
+ private linearDeviationDotLowerHalf = FSComponent.createRef();
+
+ private latchSymbol = FSComponent.createRef();
+
+ private altitude = Subject.create(new Arinc429Word(0));
+
+ onAfterRender(node: VNode): void {
+ super.onAfterRender(node);
+
+ const sub = this.props.bus.getSubscriber();
+
+ sub.on('altitude').handle((alt) => {
+ const altitude = new Arinc429Word(alt);
+
+ this.altitude.set(altitude);
+ });
+
+ sub.on('linearDeviationActive').whenChanged().handle((isActive) => {
+ this.lastIsActive = isActive;
+
+ hideOrShow(this.component)(isActive);
+ hideOrShow(this.upperLinearDeviationReadout)(isActive);
+ hideOrShow(this.lowerLinearDeviationReadout)(isActive);
+ });
+
+ sub.on('verticalProfileLatched').whenChanged().handle(hideOrShow(this.latchSymbol));
+
+ sub.on('targetAltitude').atFrequency(3).handle((targetAltitude) => {
+ const altitude = this.altitude.get();
+ if (!altitude.isNormalOperation()) {
+ return;
+ }
+
+ const deviation = altitude.value - targetAltitude;
+
+ // Only update this if it's actually active
+ if (!this.lastIsActive) {
+ return;
+ }
+
+ const pixelOffset = this.pixelOffsetFromDeviation(Math.max(Math.min(deviation, 500), -500));
+
+ this.component.instance.style.transform = `translate3d(0px, ${pixelOffset}px, 0px)`;
+
+ const linearDeviationReadoutText = Math.min(99, Math.round(Math.abs(deviation) / 100)).toFixed(0).padStart(2, '0');
+
+ this.upperLinearDeviationReadout.instance.textContent = linearDeviationReadoutText;
+ this.lowerLinearDeviationReadout.instance.textContent = linearDeviationReadoutText;
+
+ if (deviation > 540) {
+ this.lowerLinearDeviationReadout.instance.style.visibility = 'visible';
+ this.linearDeviationDotLowerHalf.instance.style.visibility = 'visible';
+
+ this.upperLinearDeviationReadout.instance.style.visibility = 'hidden';
+ this.linearDeviationDotUpperHalf.instance.style.visibility = 'hidden';
+
+ this.linearDeviationDot.instance.style.visibility = 'hidden';
+ } else if (deviation > -500 && deviation < 500) {
+ this.lowerLinearDeviationReadout.instance.style.visibility = 'hidden';
+ this.linearDeviationDotLowerHalf.instance.style.visibility = 'hidden';
+
+ this.upperLinearDeviationReadout.instance.style.visibility = 'hidden';
+ this.linearDeviationDotUpperHalf.instance.style.visibility = 'hidden';
+
+ this.linearDeviationDot.instance.style.visibility = 'visible';
+ } else if (deviation < -540) {
+ this.lowerLinearDeviationReadout.instance.style.visibility = 'hidden';
+ this.linearDeviationDotLowerHalf.instance.style.visibility = 'hidden';
+
+ this.upperLinearDeviationReadout.instance.style.visibility = 'visible';
+ this.linearDeviationDotUpperHalf.instance.style.visibility = 'visible';
+
+ this.linearDeviationDot.instance.style.visibility = 'hidden';
+ }
+ });
+ }
+
+ render(): VNode {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ private pixelOffsetFromDeviation(deviation: number) {
+ return deviation * 40.5 / 500;
+ }
+}
+
+function hideOrShow(component: NodeReference) {
+ return (isActive: boolean) => {
+ if (isActive) {
+ component.instance.removeAttribute('style');
+ } else {
+ component.instance.setAttribute('style', 'display: none');
+ }
+ };
+}
diff --git a/src/instruments/src/PFD/PFD.tsx b/src/instruments/src/PFD/PFD.tsx
index 5ed3fa96f2a6..7fdfe8183a17 100644
--- a/src/instruments/src/PFD/PFD.tsx
+++ b/src/instruments/src/PFD/PFD.tsx
@@ -12,6 +12,7 @@ import { FMA } from './FMA';
import { HeadingOfftape, HeadingTape } from './HeadingIndicator';
import { Horizon } from './AttitudeIndicatorHorizon';
import { LandingSystem } from './LandingSystemIndicator';
+import { LinearDeviationIndicator } from './LinearDeviationIndicator';
import { AirspeedIndicator, AirspeedIndicatorOfftape, MachNumber } from './SpeedIndicator';
import { VerticalSpeedIndicator } from './VerticalSpeedIndicator';
@@ -128,6 +129,7 @@ export class PFDComponent extends DisplayComponent {
+
diff --git a/src/instruments/src/PFD/SpeedIndicator.tsx b/src/instruments/src/PFD/SpeedIndicator.tsx
index 4ac5d1d544b3..a56ffd91ec5e 100644
--- a/src/instruments/src/PFD/SpeedIndicator.tsx
+++ b/src/instruments/src/PFD/SpeedIndicator.tsx
@@ -875,11 +875,68 @@ class SpeedTarget extends DisplayComponent <{ bus: EventBus }> {
{this.textSub}
{this.textSub}
+
>
);
}
}
+class SpeedMargins extends DisplayComponent<{ bus: EventBus }> {
+ private entireComponentRef = FSComponent.createRef();
+
+ private upperSpeedMarginRef = FSComponent.createRef();
+
+ private lowerSpeedMarginRef = FSComponent.createRef();
+
+ private currentSpeed = new Arinc429Word(0);
+
+ onAfterRender(node: VNode): void {
+ super.onAfterRender(node);
+
+ const sub = this.props.bus.getSubscriber();
+
+ sub.on('showSpeedMargins').handle(this.hideOrShow(this.entireComponentRef));
+
+ sub.on('speedAr').withArinc429Precision(2).handle((s) => this.currentSpeed = s);
+
+ sub.on('upperSpeedMargin').handle(this.moveToSpeed(this.upperSpeedMarginRef));
+
+ sub.on('lowerSpeedMargin').handle(this.moveToSpeed(this.lowerSpeedMarginRef));
+ }
+
+ render(): VNode {
+ return (
+
+
+
+
+ );
+ }
+
+ private moveToSpeed(component: NodeReference) {
+ return (speed: number) => {
+ const offset = (Math.round(100 * (this.currentSpeed.value - speed) * DistanceSpacing / ValueSpacing) / 100).toFixed(2);
+
+ const isInRange = Math.abs(this.currentSpeed.value - speed) < DisplayRange;
+ component.instance.style.visibility = isInRange ? 'visible' : 'hidden';
+
+ if (isInRange) {
+ component.instance.style.transform = `translate3d(0px, ${offset}px, 0px)`;
+ }
+ };
+ }
+
+ private hideOrShow(component: NodeReference) {
+ return (isActive: boolean) => {
+ if (isActive) {
+ component.instance.removeAttribute('style');
+ } else {
+ component.instance.setAttribute('style', 'display: none');
+ }
+ };
+ }
+}
+
export class MachNumber extends DisplayComponent<{bus: EventBus}> {
private machTextSub = Subject.create('');
diff --git a/src/instruments/src/PFD/instrument.tsx b/src/instruments/src/PFD/instrument.tsx
index 391906df61c6..1b82f96499a6 100644
--- a/src/instruments/src/PFD/instrument.tsx
+++ b/src/instruments/src/PFD/instrument.tsx
@@ -160,6 +160,7 @@ class A32NX_PFD extends BaseInstrument {
this.simVarPublisher.subscribe('engTwoRunning');
this.simVarPublisher.subscribe('expediteMode');
this.simVarPublisher.subscribe('setHoldSpeed');
+ this.simVarPublisher.subscribe('tdReached');
this.simVarPublisher.subscribe('vls');
this.simVarPublisher.subscribe('alphaLim');
this.simVarPublisher.subscribe('trkFpaDeselectedTCAS');
@@ -183,6 +184,13 @@ class A32NX_PFD extends BaseInstrument {
this.simVarPublisher.subscribe('ls1Button');
this.simVarPublisher.subscribe('ls2Button');
+ this.simVarPublisher.subscribe('linearDeviationActive');
+ this.simVarPublisher.subscribe('verticalProfileLatched');
+ this.simVarPublisher.subscribe('targetAltitude');
+ this.simVarPublisher.subscribe('showSpeedMargins');
+ this.simVarPublisher.subscribe('upperSpeedMargin');
+ this.simVarPublisher.subscribe('lowerSpeedMargin');
+
FSComponent.render(, document.getElementById('PFD_CONTENT'));
}
diff --git a/src/instruments/src/PFD/shared/PFDSimvarPublisher.tsx b/src/instruments/src/PFD/shared/PFDSimvarPublisher.tsx
index 17981971ef11..78643d4698d1 100644
--- a/src/instruments/src/PFD/shared/PFDSimvarPublisher.tsx
+++ b/src/instruments/src/PFD/shared/PFDSimvarPublisher.tsx
@@ -91,6 +91,7 @@ export interface PFDSimvars {
engTwoRunning: boolean;
expediteMode: boolean;
setHoldSpeed: boolean;
+ tdReached: boolean;
vls: number;
alphaLim: number;
trkFpaDeselectedTCAS: boolean;
@@ -112,6 +113,12 @@ export interface PFDSimvars {
daRaw: number;
ls1Button: boolean;
ls2Button: boolean;
+ linearDeviationActive: boolean;
+ targetAltitude: number;
+ verticalProfileLatched: boolean;
+ showSpeedMargins: boolean;
+ upperSpeedMargin: number;
+ lowerSpeedMargin: number;
}
export enum PFDVars {
@@ -205,6 +212,7 @@ export enum PFDVars {
engTwoRunning = 'GENERAL ENG COMBUSTION:2',
expediteMode = 'L:A32NX_FMA_EXPEDITE_MODE',
setHoldSpeed = 'L:A32NX_PFD_MSG_SET_HOLD_SPEED',
+ tdReached = 'L:A32NX_PFD_MSG_TD_REACHED',
vls = 'L:A32NX_SPEEDS_VLS',
alphaLim = 'L:A32NX_SPEEDS_ALPHA_MAX',
trkFpaDeselectedTCAS= 'L:A32NX_AUTOPILOT_TCAS_MESSAGE_TRK_FPA_DESELECTION',
@@ -226,6 +234,12 @@ export enum PFDVars {
daRaw = 'L:A32NX_ADIRS_IR_1_DRIFT_ANGLE',
ls1Button = 'L:BTN_LS_1_FILTER_ACTIVE',
ls2Button = 'L:BTN_LS_2_FILTER_ACTIVE',
+ linearDeviationActive = 'L:A32NX_PFD_LINEAR_DEVIATION_ACTIVE',
+ targetAltitude = 'L:A32NX_PFD_TARGET_ALTITUDE',
+ verticalProfileLatched = 'L:A32NX_PFD_VERTICAL_PROFILE_LATCHED',
+ showSpeedMargins = 'L:A32NX_PFD_SHOW_SPEED_MARGINS',
+ upperSpeedMargin = 'L:A32NX_PFD_UPPER_SPEED_MARGIN',
+ lowerSpeedMargin = 'L:A32NX_PFD_LOWER_SPEED_MARGIN',
}
/** A publisher to poll and publish nav/com simvars. */
@@ -321,6 +335,7 @@ export class PFDSimvarPublisher extends SimVarPublisher {
['engTwoRunning', { name: PFDVars.engTwoRunning, type: SimVarValueType.Bool }],
['expediteMode', { name: PFDVars.expediteMode, type: SimVarValueType.Bool }],
['setHoldSpeed', { name: PFDVars.setHoldSpeed, type: SimVarValueType.Bool }],
+ ['tdReached', { name: PFDVars.tdReached, type: SimVarValueType.Bool }],
['vls', { name: PFDVars.vls, type: SimVarValueType.Number }],
['alphaLim', { name: PFDVars.alphaLim, type: SimVarValueType.Number }],
['trkFpaDeselectedTCAS', { name: PFDVars.trkFpaDeselectedTCAS, type: SimVarValueType.Bool }],
@@ -342,6 +357,12 @@ export class PFDSimvarPublisher extends SimVarPublisher {
['daRaw', { name: PFDVars.daRaw, type: SimVarValueType.Number }],
['ls1Button', { name: PFDVars.ls1Button, type: SimVarValueType.Bool }],
['ls2Button', { name: PFDVars.ls2Button, type: SimVarValueType.Bool }],
+ ['linearDeviationActive', { name: PFDVars.linearDeviationActive, type: SimVarValueType.Bool }],
+ ['targetAltitude', { name: PFDVars.targetAltitude, type: SimVarValueType.Feet }],
+ ['verticalProfileLatched', { name: PFDVars.verticalProfileLatched, type: SimVarValueType.Bool }],
+ ['showSpeedMargins', { name: PFDVars.showSpeedMargins, type: SimVarValueType.Bool }],
+ ['upperSpeedMargin', { name: PFDVars.upperSpeedMargin, type: SimVarValueType.Knots }],
+ ['lowerSpeedMargin', { name: PFDVars.lowerSpeedMargin, type: SimVarValueType.Knots }],
])
public constructor(bus: EventBus) {
diff --git a/src/shared/src/Constants.ts b/src/shared/src/Constants.ts
index 10bfef17c4c7..595f23042db2 100644
--- a/src/shared/src/Constants.ts
+++ b/src/shared/src/Constants.ts
@@ -1,4 +1,5 @@
export const enum Constants {
G = 9.81,
EARTH_RADIUS_NM = 3440.1,
+ TONS_TO_POUNDS = 2204.62,
}
diff --git a/src/shared/src/FmMessages.ts b/src/shared/src/FmMessages.ts
index 273275bf073d..81a6240d42b9 100644
--- a/src/shared/src/FmMessages.ts
+++ b/src/shared/src/FmMessages.ts
@@ -180,4 +180,10 @@ export const FMMessageTypes: Readonly> = {
color: 'Amber',
ndPriority: 9,
},
+ TdReached: {
+ id: 16,
+ text: 'T/D REACHED',
+ color: 'White',
+ clearable: true,
+ },
};
diff --git a/src/shared/src/NavigationDisplay.ts b/src/shared/src/NavigationDisplay.ts
index 8cbb5d9c6e0d..73524a085d1d 100644
--- a/src/shared/src/NavigationDisplay.ts
+++ b/src/shared/src/NavigationDisplay.ts
@@ -36,22 +36,28 @@ export enum NdSymbolTypeFlags {
ActiveLegTermination = 1 << 7,
EfisOption = 1 << 8,
Dme = 1 << 9,
- ConstraintMet = 1 << 10,
- ConstraintMissed = 1 << 11,
- ConstraintUnknown = 1 << 12,
- SpeedChange = 1 << 13,
- FixInfo = 1 << 14,
- FlightPlan = 1 << 15,
- PwpDecel = 1 << 16,
- PwpTopOfDescent = 1 << 17,
- PwpCdaFlap1 = 1 << 18,
- PwpCdaFlap2 = 1 << 19,
- FlightPlanVectorLine = 1 << 20,
- FlightPlanVectorArc = 1 << 21,
- FlightPlanVectorDebugPoint = 1 << 22,
- ActiveFlightPlanVector = 1 << 23,
- CourseReversalLeft = 1 << 24,
- CourseReversalRight = 1 << 25,
+ Constraint = 1 << 10,
+ FixInfo = 1 << 11,
+ FlightPlan = 1 << 12,
+ FlightPlanVectorLine = 1 << 13,
+ FlightPlanVectorArc = 1 << 14,
+ FlightPlanVectorDebugPoint = 1 << 15,
+ ActiveFlightPlanVector = 1 << 16,
+ CourseReversalLeft = 1 << 17,
+ CourseReversalRight = 1 << 18,
+ PwpDecel = 1 << 19,
+ PwpTopOfDescent = 1 << 20,
+ PwpSpeedChange = 1 << 21,
+ PwpClimbLevelOff = 1 << 22,
+ PwpDescentLevelOff = 1 << 23,
+ PwpStartOfClimb = 1 << 24,
+ PwpInterceptProfile = 1 << 25,
+ PwpTimeMarker = 1 << 26,
+ PwpCdaFlap1 = 1 << 27,
+ PwpCdaFlap2 = 1 << 28,
+ CyanColor = 1 << 29,
+ AmberColor = 1 << 30,
+ MagentaColor = 1 << 31,
}
export interface NdSymbol {
@@ -68,6 +74,7 @@ export interface NdSymbol {
constraints?: string[],
radials?: number[],
radii?: number[],
+ distanceFromAirplane?: number;
}
/**
diff --git a/typings/types.d.ts b/typings/types.d.ts
index 996c208730d4..1228ab4c3a31 100644
--- a/typings/types.d.ts
+++ b/typings/types.d.ts
@@ -16,6 +16,7 @@ declare global {
type DegreesMagnetic = number;
type DegreesTrue = number;
type Seconds = number;
+ type Minutes = number;
type Percent = number;
type Radians = number;
type RotationsPerMinute = number;
@@ -24,6 +25,7 @@ declare global {
type PercentOver100 = number;
type Gallons = number;
type Kilograms = number;
+ type Pounds = number;
type Celcius = number;
type InchesOfMercury = number;
type Millibar = number;