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;