From 30d0af31240a196057611eae71b35177daafe90e Mon Sep 17 00:00:00 2001 From: BBK <22713769+BlueberryKing@users.noreply.github.com> Date: Tue, 11 Oct 2022 19:53:55 +0200 Subject: [PATCH] feat: vertical navigation v1 #7080 (@c3b141e) --- .github/CHANGELOG.md | 1 + docs/a320-simvars.md | 38 + .../Pages/A32NX_Utils/NXSystemMessages.js | 17 +- .../html_ui/Pages/A32NX_Utils/NXUnits.js | 4 + .../FlyByWire_A320_Neo/CDU/A320_Neo_CDU.html | 1 + .../CDU/A320_Neo_CDU_FlightPlanPage.js | 313 ++++--- .../CDU/A320_Neo_CDU_MainDisplay.js | 1 + .../CDU/A320_Neo_CDU_PerformancePage.js | 168 +++- .../CDU/A320_Neo_CDU_ProgressPage.js | 17 +- .../CDU/A320_Neo_CDU_StepAltsPage.js | 214 +++++ .../CDU/A320_Neo_CDU_VerticalRevisionPage.js | 74 +- .../FMC/A32NX_FMCMainDisplay.js | 150 ++- msfs-avionics-mirror/src/sdk/.npmrc | 1 + .../components/fms-messages/FmsMessages.ts | 6 + .../src/components/fms-messages/StepAhead.ts | 45 + .../components/fms-messages/StepDeleted.ts | 18 + .../src/components/fms-messages/TdReached.ts | 20 + src/fmgc/src/efis/EfisSymbols.ts | 50 +- .../src/flightplanning/FlightPlanManager.ts | 14 + .../src/flightplanning/ManagedFlightPlan.ts | 36 +- .../src/flightplanning/data/flightplan.ts | 8 + src/fmgc/src/guidance/Geometry.ts | 4 +- src/fmgc/src/guidance/Guidable.ts | 7 +- src/fmgc/src/guidance/GuidanceController.ts | 79 +- .../{PsuedoWaypoint.ts => PseudoWaypoint.ts} | 37 +- src/fmgc/src/guidance/lnav/PseudoWaypoints.ts | 522 +++++++++-- src/fmgc/src/guidance/lnav/legs/CI.ts | 4 - src/fmgc/src/guidance/lnav/legs/index.ts | 18 +- .../lnav/transitions/DirectToFixTransition.ts | 4 +- .../lnav/transitions/FixedRadiusTransition.ts | 2 +- .../lnav/transitions/PathCaptureTransition.ts | 25 +- .../guidance/vnav/AtmosphericConditions.ts | 86 +- .../src/guidance/vnav/ConstraintReader.ts | 212 +++++ .../vnav/CruiseToDescentCoordinator.ts | 157 ++++ src/fmgc/src/guidance/vnav/EngineModel.ts | 60 ++ src/fmgc/src/guidance/vnav/FlightModel.ts | 7 + src/fmgc/src/guidance/vnav/Predictions.ts | 872 ++++++++++++++---- .../VerticalProfileComputationParameters.ts | 129 +++ src/fmgc/src/guidance/vnav/VnavConfig.ts | 23 +- src/fmgc/src/guidance/vnav/VnavDriver.ts | 610 +++++++++++- .../guidance/vnav/climb/ClimbPathBuilder.ts | 425 +++++++-- .../vnav/climb/ClimbProfileBuilderResult.ts | 3 - .../src/guidance/vnav/climb/ClimbStrategy.ts | 227 +++++ .../src/guidance/vnav/climb/SpeedProfile.ts | 454 +++++++++ src/fmgc/src/guidance/vnav/common.ts | 26 +- .../guidance/vnav/cruise/CruisePathBuilder.ts | 200 ++++ .../vnav/descent/AircraftToProfileRelation.ts | 128 +++ .../vnav/descent/ApproachPathBuilder.ts | 412 +++++++++ .../guidance/vnav/descent/DecelPathBuilder.ts | 328 ------- .../guidance/vnav/descent/DescentBuilder.ts | 49 - .../guidance/vnav/descent/DescentGuidance.ts | 290 ++++++ .../vnav/descent/DescentPathBuilder.ts | 304 ++++++ .../guidance/vnav/descent/DescentStrategy.ts | 141 +++ .../vnav/descent/GeometricPathBuilder.ts | 170 ++++ .../vnav/descent/GeomtricPathBuilder.ts | 71 -- .../descent/InertialDistanceAlongTrack.ts | 24 + .../vnav/descent/LatchedDescentGuidance.ts | 243 +++++ .../descent/ProfileInterceptCalculator.ts | 27 + .../src/guidance/vnav/descent/SpeedMargin.ts | 36 + .../descent/TacticalDescentPathBuilder.ts | 180 ++++ .../vnav/descent/TheoreticalDescentPath.ts | 20 - .../src/guidance/vnav/descent/TodGuidance.ts | 106 +++ .../vnav/profile/BaseGeometryProfile.ts | 458 +++++++++ .../vnav/profile/NavGeometryProfile.ts | 282 ++++++ .../vnav/profile/SelectedGeometryProfile.ts | 38 + .../profile/TemporaryCheckpointSequence.ts | 78 ++ .../vnav/takeoff/TakeoffPathBuilder.ts | 106 +++ .../vnav/wind/AircraftHeadingProfile.ts | 74 ++ .../guidance/vnav/wind/ClimbWindProfile.ts | 63 ++ .../guidance/vnav/wind/CruiseWindProfile.ts | 65 ++ .../guidance/vnav/wind/DescentWindProfile.ts | 63 ++ .../src/guidance/vnav/wind/HeadwindProfile.ts | 25 + .../vnav/wind/WindForecastInputObserver.ts | 60 ++ .../guidance/vnav/wind/WindForecastInputs.ts | 9 + .../src/guidance/vnav/wind/WindObserver.ts | 19 + .../src/guidance/vnav/wind/WindProfile.ts | 5 + .../guidance/vnav/wind/WindProfileFactory.ts | 51 + src/fmgc/src/guidance/vnav/wind/index.ts | 50 + src/fmgc/src/guidance/vnav/wind/types.ts | 15 + src/fmgc/src/index.ts | 6 +- src/fmgc/src/utils/TimeUtils.ts | 30 + src/instruments/src/Common/definitions.scss | 1 + .../src/EFB/Settings/Pages/RealismPage.tsx | 23 + .../src/ND/elements/FlightPlan.tsx | 126 ++- src/instruments/src/ND/elements/TrackLine.tsx | 37 +- src/instruments/src/ND/pages/ArcMode.tsx | 11 +- src/instruments/src/ND/pages/RoseMode.tsx | 2 +- src/instruments/src/ND/styles.scss | 11 + src/instruments/src/PFD/FMA.tsx | 19 +- .../src/PFD/LinearDeviationIndicator.tsx | 139 +++ src/instruments/src/PFD/PFD.tsx | 2 + src/instruments/src/PFD/SpeedIndicator.tsx | 57 ++ src/instruments/src/PFD/instrument.tsx | 8 + .../src/PFD/shared/PFDSimvarPublisher.tsx | 21 + src/shared/src/Constants.ts | 1 + src/shared/src/FmMessages.ts | 18 + src/shared/src/NavigationDisplay.ts | 39 +- typings/types.d.ts | 2 + 98 files changed, 8774 insertions(+), 1158 deletions(-) create mode 100644 flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_StepAltsPage.js create mode 100644 msfs-avionics-mirror/src/sdk/.npmrc create mode 100644 src/fmgc/src/components/fms-messages/StepAhead.ts create mode 100644 src/fmgc/src/components/fms-messages/StepDeleted.ts create mode 100644 src/fmgc/src/components/fms-messages/TdReached.ts rename src/fmgc/src/guidance/{PsuedoWaypoint.ts => PseudoWaypoint.ts} (65%) create mode 100644 src/fmgc/src/guidance/vnav/ConstraintReader.ts create mode 100644 src/fmgc/src/guidance/vnav/CruiseToDescentCoordinator.ts create mode 100644 src/fmgc/src/guidance/vnav/VerticalProfileComputationParameters.ts create mode 100644 src/fmgc/src/guidance/vnav/climb/ClimbStrategy.ts create mode 100644 src/fmgc/src/guidance/vnav/climb/SpeedProfile.ts create mode 100644 src/fmgc/src/guidance/vnav/cruise/CruisePathBuilder.ts create mode 100644 src/fmgc/src/guidance/vnav/descent/AircraftToProfileRelation.ts create mode 100644 src/fmgc/src/guidance/vnav/descent/ApproachPathBuilder.ts delete mode 100644 src/fmgc/src/guidance/vnav/descent/DecelPathBuilder.ts delete mode 100644 src/fmgc/src/guidance/vnav/descent/DescentBuilder.ts create mode 100644 src/fmgc/src/guidance/vnav/descent/DescentGuidance.ts create mode 100644 src/fmgc/src/guidance/vnav/descent/DescentPathBuilder.ts create mode 100644 src/fmgc/src/guidance/vnav/descent/DescentStrategy.ts create mode 100644 src/fmgc/src/guidance/vnav/descent/GeometricPathBuilder.ts delete mode 100644 src/fmgc/src/guidance/vnav/descent/GeomtricPathBuilder.ts create mode 100644 src/fmgc/src/guidance/vnav/descent/InertialDistanceAlongTrack.ts create mode 100644 src/fmgc/src/guidance/vnav/descent/LatchedDescentGuidance.ts create mode 100644 src/fmgc/src/guidance/vnav/descent/ProfileInterceptCalculator.ts create mode 100644 src/fmgc/src/guidance/vnav/descent/SpeedMargin.ts create mode 100644 src/fmgc/src/guidance/vnav/descent/TacticalDescentPathBuilder.ts delete mode 100644 src/fmgc/src/guidance/vnav/descent/TheoreticalDescentPath.ts create mode 100644 src/fmgc/src/guidance/vnav/descent/TodGuidance.ts create mode 100644 src/fmgc/src/guidance/vnav/profile/BaseGeometryProfile.ts create mode 100644 src/fmgc/src/guidance/vnav/profile/NavGeometryProfile.ts create mode 100644 src/fmgc/src/guidance/vnav/profile/SelectedGeometryProfile.ts create mode 100644 src/fmgc/src/guidance/vnav/profile/TemporaryCheckpointSequence.ts create mode 100644 src/fmgc/src/guidance/vnav/takeoff/TakeoffPathBuilder.ts create mode 100644 src/fmgc/src/guidance/vnav/wind/AircraftHeadingProfile.ts create mode 100644 src/fmgc/src/guidance/vnav/wind/ClimbWindProfile.ts create mode 100644 src/fmgc/src/guidance/vnav/wind/CruiseWindProfile.ts create mode 100644 src/fmgc/src/guidance/vnav/wind/DescentWindProfile.ts create mode 100644 src/fmgc/src/guidance/vnav/wind/HeadwindProfile.ts create mode 100644 src/fmgc/src/guidance/vnav/wind/WindForecastInputObserver.ts create mode 100644 src/fmgc/src/guidance/vnav/wind/WindForecastInputs.ts create mode 100644 src/fmgc/src/guidance/vnav/wind/WindObserver.ts create mode 100644 src/fmgc/src/guidance/vnav/wind/WindProfile.ts create mode 100644 src/fmgc/src/guidance/vnav/wind/WindProfileFactory.ts create mode 100644 src/fmgc/src/guidance/vnav/wind/index.ts create mode 100644 src/fmgc/src/guidance/vnav/wind/types.ts create mode 100644 src/fmgc/src/utils/TimeUtils.ts create mode 100644 src/instruments/src/PFD/LinearDeviationIndicator.tsx diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index 96fc487e2d2..0373b08c417 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -110,6 +110,7 @@ 1. [FAC] Move Speedscale computation to FAC - @lukecologne (luke) 1. [EFB] Added deboarding button to flyPad Payload - @frankkopp (Frank Kopp) 1. [EFB] Improved simbridge-client connection handling - @frankkopp (Frank Kopp) +1. [EFB] Added pause at T/D function - @2hwk (2Cas#1022) ## 0.8.0 diff --git a/docs/a320-simvars.md b/docs/a320-simvars.md index b5cfd435359..c385f92f8de 100644 --- a/docs/a320-simvars.md +++ b/docs/a320-simvars.md @@ -1286,6 +1286,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 @@ -1642,6 +1671,15 @@ In the variables below, {number} should be replaced with one item in the set: { - 1 - captain's side FMGC - 2 - f/o's side FMGC +- 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_TRIGGER_STEP_DELETED + - Bool + - Indicates whether to trigger a step deleted message on the MCDU + ## 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 76b7ddc5105..3e466cbfb8b 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 @@ -91,7 +91,22 @@ const NXSystemMessages = { systemBusy: new TypeIMessage("SYSTEM BUSY-TRY LATER"), uplinkInsertInProg: new TypeIIMessage("UPLINK INSERT IN PROG"), vToDisagree: new TypeIIMessage("V1/VR/V2 DISAGREE", true), - waitForSystemResponse: new TypeIMessage("WAIT FOR SYSTEM RESPONSE") + waitForSystemResponse: new TypeIMessage("WAIT FOR SYSTEM RESPONSE"), + // FIXME these should be in alphabetical order like the rest + comUnavailable: new TypeIMessage("COM UNAVAILABLE"), + dcduFileFull: new TypeIMessage("DCDU FILE FULL"), + systemBusy: new TypeIMessage("SYSTEM BUSY-TRY LATER"), + noAtc: new TypeIMessage("NO ACTIVE ATC"), + newAtisReceived: new TypeIMessage("NEW ATIS: READ AGAIN"), + 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"), + keyNotActive: new TypeIMessage("KEY NOT ACTIVE"), + latLonAbreviated: new TypeIMessage("LAT/LON DISPL ABREVIATED"), + stepAboveMaxFl: new TypeIIMessage("STEP ABOVE MAX FL"), + stepAhead: new TypeIIMessage("STEP AHEAD"), + stepDeleted: new TypeIIMessage("STEP DELETED"), }; 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 bf7b72f28a9..b9a65f8a910 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 / 0.4535934; } + 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 abe950855fb..24d3d3a0dbe 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 @@ -154,6 +154,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 d46435891af..a426c0eaafa 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(); @@ -82,12 +90,27 @@ class CDUFlightPlanPage { // FPM is trying to advance to the next one. const first = (mcdu.flightPhaseManager.phase <= FmgcFlightPhases.TAKEOFF) ? 0 : activeFirst; - // PWPs + // VNAV + const fmsGeometryProfile = mcdu.guidanceController.vnavDriver.currentNavGeometryProfile; const fmsPseudoWaypoints = mcdu.guidanceController.currentPseudoWaypoints; + 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; @@ -264,7 +297,7 @@ 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; @@ -273,14 +306,28 @@ class CDUFlightPlanPage { const gp = wp.additionalData.verticalAngle ? `${wp.additionalData.verticalAngle.toFixed(1)}°` : undefined; + 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 (!fpm.isCurrentFlightPlanTemporary()) { + if (verticalWaypoint && verticalWaypoint.speed) { + speedConstraint = verticalWaypoint.speed < 1 ? formatMachNumber(verticalWaypoint.speed) : Math.round(verticalWaypoint.speed); + + if (wp.speedConstraint > 100) { + speedPrefix = verticalWaypoint.isSpeedConstraintMet ? "{magenta}*{end}" : "{amber}*{end}"; + } + } else if (wp.speedConstraint > 100) { + speedConstraint = Math.round(wp.speedConstraint); + spdColor = "magenta"; + slashColor = "magenta"; + } } - let altColor = color; - let spdColor = color; - let timeColor = color; + speedConstraint = speedPrefix + speedConstraint; // Altitude const hasAltConstraint = wp.legAltitudeDescription > 0 && wp.legAltitudeDescription < 6; @@ -289,7 +336,6 @@ class CDUFlightPlanPage { if (fpIndex === fpm.getDestinationIndex()) { // Only for destination waypoint, show runway elevation. altColor = "white"; - spdColor = "white"; const [rwTxt, rwAlt] = getRunwayInfo(fpm.getDestinationRunway()); if (rwTxt && rwAlt) { altPrefix = "{magenta}*{end}"; @@ -307,88 +353,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 + } else if (!fpm.isCurrentFlightPlanTemporary()) { + let altitudeToFormat = wp.legAltitude1; 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"); + if (verticalWaypoint && verticalWaypoint.altitude) { + altitudeToFormat = verticalWaypoint.altitude; } - } - 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"); - } - // 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")}`; - } - 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); - } - }; + altColor = "magenta"; + slashColor = "magenta"; } - // 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 = "-----"; } } @@ -400,7 +388,7 @@ class CDUFlightPlanPage { altColor = "white"; } - if (fpIndex !== fpm.getDestinationIndex()) { + if (timeCell !== "----[s-text]") { timeColor = color; } else { timeColor = "white"; @@ -416,21 +404,22 @@ class CDUFlightPlanPage { } scrollWindow[rowI] = { - fpIndex: fpIndex, + fpIndex, active: wpActive, - ident: ident, - color: color, - distance: distance, + ident, + color, + distance, gp, - spdColor: spdColor, - speedConstraint: speedConstraint, - altColor: altColor, + 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()) { @@ -491,7 +480,7 @@ class CDUFlightPlanPage { addRskAt(rowI, () => mcdu.getDelaySwitchPage(), (value, scratchpadCallback) => { if (value === "") { - CDUVerticalRevisionPage.ShowPage(mcdu, wp); + CDUVerticalRevisionPage.ShowPage(mcdu, wp, verticalWaypoint); } else if (value === FMCMainDisplay.clrValue) { mcdu.setScratchpadMessage(NXSystemMessages.notAllowed); } else { @@ -500,22 +489,65 @@ class CDUFlightPlanPage { }); } else if (pwp) { + const color = !fpm.isCurrentFlightPlanTemporary() ? "green" : "yellow"; + + // TODO: PWP should not be shown while predictions are recomputed or in a temporary flight plan, + // but if I don't show them, the flight plan jumps around because the offset is no longer correct if the number of items in the flight plan changes. + // Like this, they are still there, but have dashes for predictions. + const shouldHidePredictions = !fmsGeometryProfile || !fmsGeometryProfile.isReadyToDisplay || !pwp.flightPlanInfo; + + let timeCell = "----[s-text]"; + let timeColor = "white"; + if (!shouldHidePredictions && Number.isFinite(pwp.flightPlanInfo.secondsFromPresent)) { + const utcTime = SimVar.GetGlobalVarValue("ZULU TIME", "seconds"); + timeColor = color; + + timeCell = isFlying + ? `${FMCMainDisplay.secondsToUTC(utcTime + pwp.flightPlanInfo.secondsFromPresent)}[s-text]` + : `${FMCMainDisplay.secondsTohhmm(pwp.flightPlanInfo.secondsFromPresent)}[s-text]`; + } + + let speed = "---"; + let spdColor = "white"; + if (!shouldHidePredictions && Number.isFinite(pwp.flightPlanInfo.speed)) { + speed = pwp.flightPlanInfo.speed < 1 ? formatMachNumber(pwp.flightPlanInfo.speed) : Math.round(pwp.flightPlanInfo.speed).toFixed(0); + spdColor = color; + } + + const altitudeConstraint = { + alt: "-----", + altPrefix: "\xa0" + }; + let altColor = "white"; + if (!shouldHidePredictions && Number.isFinite(pwp.flightPlanInfo.altitude)) { + altitudeConstraint.alt = formatAltitudeOrLevel(pwp.flightPlanInfo.altitude); + altColor = color; + } + scrollWindow[rowI] = { fpIndex: fpIndex, active: false, - ident: 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: "", + ident: pwp.mcduIdent || pwp.ident, + color, + distance: !shouldHidePredictions && pwp.distanceInFP ? Math.round(pwp.distanceInFP).toFixed(0) : "", + spdColor, + speedConstraint: speed, + altColor, + altitudeConstraint, + timeCell, + timeColor, + fixAnnotation: `{${color}}${pwp.mcduHeader || ''}{end}`, bearingTrack: "", isOverfly: false, + slashColor: color }; + + addLskAt(rowI, 0, (value, scratchpadCallback) => { + if (value === FMCMainDisplay.clrValue) { + // TODO + mcdu.setScratchpadMessage(NXSystemMessages.notAllowed); + } + }); } else if (marker) { // Marker @@ -682,26 +714,33 @@ class CDUFlightPlanPage { } let destTimeCell = "----"; let destDistCell = "---"; - let destEFOBCell = "---"; + let destEFOBCell = "-----"; if (fpm.getDestination()) { 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); + } + + if (fmsGeometryProfile && fmsGeometryProfile.isReadyToDisplay) { + const destEfob = fmsGeometryProfile.getRemainingFuelAtDestination(); + if (Number.isFinite(destEfob)) { + destEFOBCell = (NXUnits.poundsToUser(destEfob) / 1000).toFixed(1); + } + + const timeRemaining = fmsGeometryProfile.getTimeToDestination(); + if (Number.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}`]; + destText[0] = ["\xa0DEST", "DIST\xa0\xa0EFOB", isFlying ? "\xa0UTC{sp}{sp}{sp}{sp}" : "TIME{sp}{sp}{sp}{sp}"]; + destText[1] = [destCell, `{small}${destDistCell}\xa0${destEFOBCell.padStart(5, '\xa0')}{end}`, `{small}${destTimeCell}{end}{sp}{sp}{sp}{sp}`]; addLskAt(5, () => mcdu.getDelaySwitchPage(), () => { @@ -795,11 +834,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}` ]; } @@ -816,8 +855,8 @@ function emptyFplnPage() { ["-----NO ALTN F-PLN-------"], [""], [""], - ["\xa0DEST", "DIST EFOB", "TIME{sp}{sp}{sp}{sp}"], - ["------", "---- ----", "----{sp}{sp}{sp}{sp}"] + ["\xa0DEST", "DIST\xa0\xa0EFOB", "TIME{sp}{sp}{sp}{sp}"], + ["-------", "----\xa0---.-", "----{sp}{sp}{sp}{sp}"] ]; } @@ -839,3 +878,7 @@ function legTurnIsForced(wp) { // eslint-disable-next-line semi-spacing && wp.additionalData.legType !== 1 /* AF */ && wp.additionalData.legType !== 17 /* RF */; } + +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_MainDisplay.js b/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_MainDisplay.js index 362c1cce19c..e098b93f492 100644 --- a/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_MainDisplay.js +++ b/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_MainDisplay.js @@ -117,6 +117,7 @@ class A320_Neo_CDU_MainDisplay extends FMCMainDisplay { AOCRcvdMsgs: 74, AOCSentMsgs: 75, AOCFreeText: 76, + StepAltsPage: 77, }; this.mcduServerClient = undefined; 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 557101fb172..4dc530db7d7 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 @@ -435,6 +435,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) { @@ -445,7 +470,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)) { @@ -498,16 +523,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 ]); @@ -530,15 +555,19 @@ class CDUPerformancePage { const isPhaseActive = mcdu.flightPhaseManager.phase === FmgcFlightPhases.CRUISE; const titleColor = isPhaseActive ? "green" : "white"; const isSelected = Simplane.getAutoPilotAirspeedSelected(); - const isFlying = false; + const isFlying = mcdu.flightPhaseManager.phase >= FmgcFlightPhases.TAKEOFF; const actModeCell = isSelected ? "SELECTED" : "MANAGED"; const costIndexCell = isFinite(mcdu.costIndex) ? mcdu.costIndex.toFixed(0) + "[color]cyan" : "[][color]cyan"; - let managedSpeedCell = ""; + let managedSpeedCell = "---/---"; const managedSpeed = isPhaseActive ? mcdu.managedSpeedTarget : mcdu.managedSpeedCruise; if (isFinite(managedSpeed)) { - managedSpeedCell = (isSelected ? "*" : "") + managedSpeed.toFixed(0); + managedSpeedCell = managedSpeed.toFixed(0) + "[color]green"; + } + const preselTitle = isPhaseActive ? "" : "\xa0PRESEL"; + let preselCell = ""; + if (!isPhaseActive) { + preselCell = (isFinite(mcdu.preSelectedCrzSpeed) ? Math.round(mcdu.preSelectedCrzSpeed).toFixed(0) : "\xa0*[ ]") + "[color]cyan"; } - const [selectedSpeedTitle, selectedSpeedCell] = CDUPerformancePage.getSelectedTitleAndValue(isPhaseActive, isSelected, mcdu.preSelectedCrzSpeed); mcdu.onLeftInput[1] = (value, scratchpadCallback) => { if (mcdu.tryUpdateCostIndex(value)) { CDUPerformancePage.ShowCRZPage(mcdu); @@ -550,6 +579,10 @@ class CDUPerformancePage { if (isFlying) { timeLabel = "UTC"; } + const [destEfobCell, destTimeCell] = CDUPerformancePage.formatDestEfobAndTime(mcdu, isFlying); + const desCabinRateCell = "{small}-350{end}"; + const shouldShowStepAltsOption = mcdu.flightPhaseManager.phase <= FmgcFlightPhases.CRUISE && mcdu.guidanceController.vnavDriver.currentNavGeometryProfile.isReadyToDisplay; + const bottomRowLabels = ["\xa0PREV", "NEXT\xa0"]; const bottomRowCells = [""]; mcdu.leftInputDelay[5] = () => { @@ -583,6 +616,18 @@ class CDUPerformancePage { CDUPerformancePage.ShowCLBPage(mcdu); }; } + mcdu.onRightInput[3] = () => { + // DES CABIN RATE + mcdu.setScratchpadMessage(NXFictionalMessages.notYetImplemented); + }; + if (shouldShowStepAltsOption) { + CDUStepAltsPage.Return = () => { + CDUPerformancePage.ShowCRZPage(mcdu, false); + }; + mcdu.onRightInput[4] = () => { + CDUStepAltsPage.ShowPage(mcdu); + }; + } mcdu.rightInputDelay[5] = () => { return mcdu.getDelaySwitchPage(); }; @@ -591,16 +636,16 @@ class CDUPerformancePage { }; mcdu.setTemplate([ ["CRZ[color]" + titleColor], - ["ACT MODE", "EFOB", timeLabel], - [actModeCell + "[color]green", "6.0[color]green", "----[color]green"], + ["ACT MODE", "DEST EFOB", timeLabel], + [actModeCell + "[color]green", destEfobCell, destTimeCell], ["\xa0CI"], [costIndexCell + "[color]cyan"], ["\xa0MANAGED"], - ["\xa0" + managedSpeedCell + "[color]green"], - ["\xa0" + selectedSpeedTitle], - ["\xa0" + selectedSpeedCell + "[color]cyan"], - ["", "DES CABIN RATE>"], - ["", "-350FT/MIN[color]green"], + ["\xa0" + managedSpeedCell], + [preselTitle, "DES CABIN RATE"], + [preselCell, `\xa0{cyan}${desCabinRateCell}{end}{white}{small}FT/MN{end}{end}`], + [""], + ["", shouldShowStepAltsOption ? "STEP ALTS>" : ""], bottomRowLabels, bottomRowCells ]); @@ -622,7 +667,7 @@ class CDUPerformancePage { }; const isPhaseActive = mcdu.flightPhaseManager.phase === FmgcFlightPhases.DESCENT; const titleColor = isPhaseActive ? "green" : "white"; - const isFlying = false; + const isFlying = mcdu.flightPhaseManager.phase >= FmgcFlightPhases.TAKEOFF; const isSelected = Simplane.getAutoPilotAirspeedSelected(); const actModeCell = isSelected ? "SELECTED" : "MANAGED"; const costIndexCell = isFinite(mcdu.costIndex) ? mcdu.costIndex.toFixed(0) + "[color]cyan" : "[][color]cyan"; @@ -636,6 +681,8 @@ class CDUPerformancePage { if (isFlying) { timeLabel = "UTC"; } + const [destEfobCell, destTimeCell] = CDUPerformancePage.formatDestEfobAndTime(mcdu, isFlying); + const bottomRowLabels = ["\xa0PREV", "NEXT\xa0"]; const bottomRowCells = [""]; mcdu.leftInputDelay[5] = () => { @@ -684,14 +731,14 @@ class CDUPerformancePage { }; mcdu.setTemplate([ ["DES[color]" + titleColor], - ["ACT MODE", "EFOB", timeLabel], - [actModeCell + "[color]green", "6.0[color]green", "----[color]green"], + ["ACT MODE", "DEST EFOB", timeLabel], + [actModeCell + "[color]green", destEfobCell, destTimeCell], ["\xa0CI"], [costIndexCell + "[color]cyan"], ["\xa0MANAGED"], ["\xa0" + managedSpeedCell + "[color]green"], ["\xa0" + selectedSpeedTitle], - ["\xa0" + selectedSpeedCell + "[color]cyan"], + ["\xa0" + selectedSpeedCell], [""], [""], bottomRowLabels, @@ -1102,10 +1149,81 @@ 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]; + } + static formatDestEfobAndTime(mcdu, isFlying) { + const destinationPrediction = mcdu.guidanceController.vnavDriver.getDestinationPrediction(); + + let destEfobCell = "---.-"; + let destTimeCell = "----"; + + if (destinationPrediction) { + if (isFinite(destinationPrediction.estimatedFuelOnBoard)) { + destEfobCell = (NXUnits.poundsToUser(destinationPrediction.estimatedFuelOnBoard) / 1000).toFixed(1) + "[color]green"; + } + + if (isFinite(destinationPrediction.secondsFromPresent)) { + const utcTime = SimVar.GetGlobalVarValue("ZULU TIME", "seconds"); + + const predToTimeCellText = isFlying + ? FMCMainDisplay.secondsToUTC(utcTime + destinationPrediction.secondsFromPresent) + : FMCMainDisplay.secondsTohhmm(destinationPrediction.secondsFromPresent); + + destTimeCell = predToTimeCellText + "[color]green"; + } + } + + return [destEfobCell, destTimeCell]; } } 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 e3e854d43ac..a95505fcdf3 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) { @@ -150,7 +165,7 @@ class CDUProgressPage { ["\xa0" + "CRZ\xa0", "OPT\xa0\xa0\xa0\xa0REC MAX"], [flCrz, flOpt + "\xa0\xa0\xa0\xa0" + "{magenta}FL" + flMax.toString() + "\xa0{end}"], [""], - [" { }; + + mcdu.page.Current = mcdu.page.StepAltsPage; + mcdu.page.SelfPtr = setTimeout(() => { + if (mcdu.page.Current === mcdu.page.StepAltsPage) { + CDUStepAltsPage.ShowPage(mcdu); + } + }, mcdu.PageTimeout.Medium); + + const flightPhase = SimVar.GetSimVarValue("L:A32NX_FWC_FLIGHT_PHASE", "Enum"); + const isFlying = flightPhase >= 5 && flightPhase <= 7; + const transitionAltitude = mcdu.flightPlanManager.originTransitionAltitude; + 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(mcdu.guidanceController.vnavDriver.currentNavGeometryProfile.cruiseSteps), + [""], + [" CDUStepAltsPage.tryParseLeftInput(mcdu, 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.currentNavGeometryProfile.cruiseSteps; + + if (index > enteredStepAlts.length) { + return [""]; + } else if (index === enteredStepAlts.length) { + return ["{cyan}" + emptyField + "/" + emptyField + "{end}"]; + } else { + const step = enteredStepAlts[index]; + const waypoint = mcdu.flightPlanManager.getWaypoint(step.waypointIndex); + + 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) + "/" + waypoint.ident + "{end}", lastColumn]; + } + } + + static tryParseLeftInput(mcdu, index, input) { + if (index < mcdu.guidanceController.vnavDriver.currentNavGeometryProfile.cruiseSteps.length) { + return this.onClickExistingStepClimb(mcdu, index, input); + } + + // Create new step altitude + if (mcdu.guidanceController.vnavDriver.currentNavGeometryProfile.cruiseSteps.length >= 4) { + mcdu.setScratchpadMessage(NXSystemMessages.notAllowed); + return false; + } + + const splitInputs = input.split("/"); + const rawAltitudeInput = splitInputs[0]; + const rawIdentInput = splitInputs[1]; + + if (!rawIdentInput) { + // OPT STEP + mcdu.setScratchpadMessage(NXFictionalMessages.notYetImplemented); + return false; + } + + const alt = this.tryParseAltitude(mcdu, rawAltitudeInput); + if (!alt) { + return false; + } + + if (!mcdu.flightPlanManager.tryAddOrUpdateCruiseStep(rawIdentInput, alt)) { + mcdu.setScratchpadMessage(NXSystemMessages.formatError); + return false; + } + + 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.setScratchpadMessage(NXSystemMessages.formatError); + return false; + } + + altValue = Math.round(altValue / 10) * 10; + if (altValue < 1000 || altValue > 45000) { + mcdu.setScratchpadMessage(NXSystemMessages.entryOutOfRange); + return false; + } else if (altValue > 39800) { + mcdu.setScratchpadMessage(NXSystemMessages.stepAboveMaxFl); + return false; + } + + return altValue; + } + + static onClickExistingStepClimb(mcdu, index, input) { + const existingStep = mcdu.guidanceController.vnavDriver.currentNavGeometryProfile.cruiseSteps[index]; + + if (input === FMCMainDisplay.clrValue) { + mcdu.flightPlanManager.tryRemoveCruiseStep(existingStep.waypointIndex); + + return true; + } + + // Edit step + const splitInputs = input.split("/"); + if (splitInputs.length === 1) { + // Altitude + const altitude = this.tryParseAltitude(mcdu, splitInputs[0]); + + if (altitude && mcdu.flightPlanManager.tryAddOrUpdateCruiseStep(existingStep, altitude)) { + mcdu.flightPlanManager.tryRemoveCruiseStep(existingStep.waypointIndex); + + return true; + } + } else if (splitInputs.length === 2) { + const rawAltitudeInput = splitInputs[0]; + const rawIdentInput = splitInputs[1]; + + if (rawAltitudeInput === "") { + // /Waypoint + if (mcdu.flightPlanManager.tryAddOrUpdateCruiseStep(rawIdentInput, existingStep.toAltitude)) { + mcdu.flightPlanManager.tryRemoveCruiseStep(existingStep.waypointIndex); + + return true; + } + } else { + // Altitude/waypoint + const altitude = this.tryParseAltitude(mcdu, rawAltitudeInput); + + if (altitude && mcdu.flightPlanManager.tryAddOrUpdateCruiseStep(rawIdentInput, altitude)) { + mcdu.flightPlanManager.tryRemoveCruiseStep(existingStep.waypointIndex); + + return true; + } + } + } else if (splitInputs.length === 3) { + // Altitude/place/distance or + // /Place/distance + mcdu.setScratchpadMessage(NXFictionalMessages.notYetImplemented); + return false; + } + + mcdu.setScratchpadMessage(NXSystemMessages.formatError); + 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 e76ce487461..08b9ae9d524 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, confirmSpeed = undefined, confirmAlt = undefined, confirmCode = undefined) { const waypointInfo = waypoint.infos; if (waypointInfo instanceof WayPointInfo) { mcdu.clearDisplay(); @@ -96,7 +96,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 @@ -124,7 +126,7 @@ class CDUVerticalRevisionPage { } mcdu.onLeftInput[3] = (value, scratchpadCallback) => { if (mcdu.setPerfApprQNH(value)) { - CDUVerticalRevisionPage.ShowPage(mcdu, waypoint, confirmSpeed, confirmAlt, confirmCode); + CDUVerticalRevisionPage.ShowPage(mcdu, waypoint, verticalWaypoint, confirmSpeed, confirmAlt, confirmCode); } else { scratchpadCallback(); } @@ -140,6 +142,12 @@ class CDUVerticalRevisionPage { if (speedConstraint) { l3Cell = `{magenta}${speedConstraint}{end}`; } + + [r4Title, r4Cell] = this.formatAltErrorTitleAndValue(waypoint, verticalWaypoint); + + if (mcdu._cruiseEntered && mcdu._cruiseFlightLevel && (mcdu.flightPhaseManager.phase < FmgcFlightPhases.DESCENT || mcdu.flightPhaseManager.phase > FmgcFlightPhases.GOAROUND)) { + r5Cell = "STEP ALTS>"; + }; } mcdu.setTemplate([ @@ -150,8 +158,8 @@ class CDUVerticalRevisionPage { [speedLimitCell, "RTA>[color]inop"], [l3Title, r3Title], [l3Cell, r3Cell], - [l4Title, ""], - [l4Cell, ""], + [l4Title, r4Title], + [l4Cell, r4Cell], [""], [" {}; // RTA @@ -223,7 +231,7 @@ class CDUVerticalRevisionPage { if (value === FMCMainDisplay.clrValue) { mcdu.flightPlanManager.setWaypointSpeed(-1, wpIndex, () => { this.ShowPage(mcdu, waypoint); - }); + }, constraintType === WaypointConstraintType.DES); return; } @@ -242,12 +250,12 @@ class CDUVerticalRevisionPage { } if (constraintType === WaypointConstraintType.Unknown) { - CDUVerticalRevisionPage.ShowPage(mcdu, waypoint, speed); + CDUVerticalRevisionPage.ShowPage(mcdu, waypoint, verticalWaypoint, speed); return; } mcdu.flightPlanManager.setWaypointSpeed(speed, wpIndex, () => { - this.ShowPage(mcdu, waypoint); + this.ShowPage(mcdu, waypoint, verticalWaypoint); }, constraintType === WaypointConstraintType.DES); }; // SPD CSTR mcdu.onRightInput[2] = (value, scratchpadCallback) => { @@ -255,7 +263,7 @@ class CDUVerticalRevisionPage { mcdu.flightPlanManager.setLegAltitudeDescription(waypoint, 0); mcdu.flightPlanManager.setWaypointAltitude(0, wpIndex, () => { mcdu.updateConstraints(); - this.ShowPage(mcdu, waypoint); + this.ShowPage(mcdu, waypoint, verticalWaypoint); }); return; } @@ -277,7 +285,7 @@ class CDUVerticalRevisionPage { } if (constraintType === WaypointConstraintType.Unknown) { - CDUVerticalRevisionPage.ShowPage(mcdu, waypoint, undefined, altitude, code); + CDUVerticalRevisionPage.ShowPage(mcdu, waypoint, verticalWaypoint, undefined, altitude, code); return; } @@ -290,11 +298,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); @@ -327,6 +343,7 @@ class CDUVerticalRevisionPage { } }; } + } } @@ -387,12 +404,13 @@ class CDUVerticalRevisionPage { const type = CDUVerticalRevisionPage.constraintType(mcdu, waypoint); if (type === WaypointConstraintType.Unknown) { - CDUVerticalRevisionPage.ShowPage(mcdu, waypoint, speed, alt, code); + CDUVerticalRevisionPage.ShowPage(mcdu, waypoint, verticalWaypoint, speed, alt, code); return; } if (speed !== undefined) { mcdu.flightPlanManager.setWaypointSpeed(speed, mcdu.flightPlanManager.indexOfWaypoint(waypoint), () => { + mcdu.guidanceController.vnavDriver.invalidateFlightPlanProfile(); CDUFlightPlanPage.ShowPage(mcdu, offset); }, type === WaypointConstraintType.DES); } @@ -400,8 +418,34 @@ class CDUVerticalRevisionPage { if (alt !== undefined) { mcdu.flightPlanManager.setLegAltitudeDescription(waypoint, code); mcdu.flightPlanManager.setWaypointAltitude(alt, mcdu.flightPlanManager.indexOfWaypoint(waypoint), () => { + mcdu.guidanceController.vnavDriver.invalidateFlightPlanProfile(); CDUFlightPlanPage.ShowPage(mcdu, offset); }, 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 cf004e0ccf4..ca9b352c75e 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 @@ -1088,17 +1088,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: { @@ -1380,10 +1376,13 @@ class FMCMainDisplay extends BaseAirliners { this.computedVls = SimVar.GetSimVarValue("L:A32NX_SPEEDS_VLS", "number"); let weight = this.tryEstimateLandingWeight(); + const vnavPrediction = this.guidanceController.vnavDriver.getDestinationPrediction(); // Actual weight is used during approach phase (FCOM bulletin 46/2), and we also assume during go-around // Fallback gross weight set to 64.3T (MZFW), which is replaced by FMGW once input in FMS to avoid function returning undefined results. if (this.flightPhaseManager.phase >= FmgcFlightPhases.APPROACH || !isFinite(weight)) { weight = (this.getGW() == 0) ? 64.3 : this.getGW(); + } else if (vnavPrediction) { + weight = this.zeroFuelWeight + vnavPrediction.estimatedFuelOnBoard * 0.4535934 / 1000; } // if pilot has set approach wind in MCDU we use it, otherwise fall back to current measured wind if (isFinite(this.perfApprWindSpeed) && isFinite(this.perfApprWindHeading)) { @@ -1752,12 +1751,33 @@ class FMCMainDisplay extends BaseAirliners { (this.flightPhaseManager.phase === FmgcFlightPhases.CLIMB && _targetFl > this.cruiseFlightLevel) || (this.flightPhaseManager.phase === FmgcFlightPhases.CRUISE && _targetFl !== this.cruiseFlightLevel) ) { + this.deleteOutdatedCruiseSteps(this.cruiseFlightLevel, _targetFl); this.addMessageToQueue(NXSystemMessages.newCrzAlt.getModifiedMessage(_targetFl * 100)); this.cruiseFlightLevel = _targetFl; this._cruiseFlightLevel = _targetFl; } } + deleteOutdatedCruiseSteps(oldCruiseLevel, newCruiseLevel) { + const isClimbVsDescent = newCruiseLevel > oldCruiseLevel; + + for (let i = this.flightPlanManager.getActiveWaypointIndex(); i < this.flightPlanManager.getWaypointsCount(); i++) { + const waypoint = this.flightPlanManager.getWaypoint(i); + if (!waypoint || !waypoint.additionalData.cruiseStep) { + continue; + } + + const stepLevel = Math.round(waypoint.additionalData.cruiseStep.toAltitude / 100); + + if (isClimbVsDescent && stepLevel >= oldCruiseLevel && stepLevel <= newCruiseLevel || + !isClimbVsDescent && stepLevel <= oldCruiseLevel && stepLevel >= newCruiseLevel + ) { + waypoint.additionalData.cruiseStep = undefined; + this.removeMessageFromQueue(NXSystemMessages.stepAhead.text); + } + } + } + /*** * Executed on every alt knob turn, checks whether or not the crz fl can be changed to the newly selected fcu altitude * It creates a timeout to simulate real life delay which resets every time the fcu knob alt increases or decreases. @@ -2834,6 +2854,7 @@ class FMCMainDisplay extends BaseAirliners { this.clearAutotunedIls(); this.tempFpPendingAutoTune = false; } + this.guidanceController.vnavDriver.invalidateFlightPlanProfile(); callback(); }); }).catch(console.error); @@ -5050,6 +5071,115 @@ 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; + } + + getOriginTransitionAltitude() { + return this.flightPlanManager.getOriginTransitionAltitude(); + } + + 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; + } + + 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 }; + } + getApproachQnh() { + return this.perfApprQNH; + } + getApproachTemperature() { + return this.perfApprTemp; + } + } 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 00000000000..43c97e719a5 --- /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 51b9c96b8ee..b1d3b69d89c 100644 --- a/src/fmgc/src/components/fms-messages/FmsMessages.ts +++ b/src/fmgc/src/components/fms-messages/FmsMessages.ts @@ -4,7 +4,10 @@ import { TurnAreaExceedanceLeft, TurnAreaExceedanceRight } from '@fmgc/components/fms-messages/TurnAreaExceedance'; import { FlightPlanManager } from '@shared/flightplan'; +import { TdReached } from '@fmgc/components/fms-messages/TdReached'; import { FMMessage, FMMessageTriggers } from '@shared/FmMessages'; +import { StepAhead } from '@fmgc/components/fms-messages/StepAhead'; +import { StepDeleted } from '@fmgc/components/fms-messages/StepDeleted'; import { FmgcComponent } from '../FmgcComponent'; import { GpsPrimary } from './GpsPrimary'; import { GpsPrimaryLost } from './GpsPrimaryLost'; @@ -39,6 +42,9 @@ export class FmsMessages implements FmgcComponent { new MapPartlyDisplayedRight(), new TurnAreaExceedanceLeft(), new TurnAreaExceedanceRight(), + new TdReached(), + new StepAhead(), + new StepDeleted(), ]; init(baseInstrument: BaseInstrument, _flightPlanManager: FlightPlanManager): void { diff --git a/src/fmgc/src/components/fms-messages/StepAhead.ts b/src/fmgc/src/components/fms-messages/StepAhead.ts new file mode 100644 index 00000000000..5d3b2aed9f6 --- /dev/null +++ b/src/fmgc/src/components/fms-messages/StepAhead.ts @@ -0,0 +1,45 @@ +import { FlightPlans } from '@fmgc/flightplanning/FlightPlanManager'; +import { GuidanceController } from '@fmgc/guidance/GuidanceController'; +import { FMMessage, FMMessageTypes } from '@shared/FmMessages'; +import { FMMessageSelector, FMMessageUpdate } from './FmsMessages'; + +export class StepAhead implements FMMessageSelector { + message: FMMessage = FMMessageTypes.StepAhead; + + private guidanceController: GuidanceController; + + private lastState: boolean = false; + + init(baseInstrument: BaseInstrument): void { + this.guidanceController = baseInstrument.guidanceController; + } + + process(_: number): FMMessageUpdate { + const fpm = this.guidanceController.flightPlanManager; + const distanceToEnd = this.guidanceController.vnavDriver.distanceToEnd; + + if (!this.guidanceController.vnavDriver.currentNavGeometryProfile.isReadyToDisplay || distanceToEnd <= 0) { + return FMMessageUpdate.NO_ACTION; + } + + let newState = false; + for (let i = fpm.getActiveWaypointIndex(); i < fpm.getWaypointsCount(FlightPlans.Active); i++) { + const waypoint = fpm.getWaypoint(i, FlightPlans.Active); + if (!waypoint || !waypoint.additionalData.cruiseStep) { + continue; + } + + if (distanceToEnd - waypoint.additionalData.distanceToEnd < 20) { + newState = true; + } + } + + if (newState !== this.lastState) { + this.lastState = newState; + + return newState ? FMMessageUpdate.SEND : FMMessageUpdate.RECALL; + } + + return FMMessageUpdate.NO_ACTION; + } +} diff --git a/src/fmgc/src/components/fms-messages/StepDeleted.ts b/src/fmgc/src/components/fms-messages/StepDeleted.ts new file mode 100644 index 00000000000..c87df2e3d23 --- /dev/null +++ b/src/fmgc/src/components/fms-messages/StepDeleted.ts @@ -0,0 +1,18 @@ +import { FMMessage, FMMessageTypes } from '@shared/FmMessages'; +import { FMMessageSelector, FMMessageUpdate } from './FmsMessages'; + +export class StepDeleted implements FMMessageSelector { + message: FMMessage = FMMessageTypes.StepDeleted; + + process(_: number): FMMessageUpdate { + const newState = SimVar.GetSimVarValue('L:A32NX_FM_VNAV_TRIGGER_STEP_DELETED', 'Bool') === 1; + + if (newState) { + SimVar.SetSimVarValue('L:A32NX_FM_VNAV_TRIGGER_STEP_DELETED', 'boolean', false); + + return FMMessageUpdate.SEND; + } + + return FMMessageUpdate.NO_ACTION; + } +} 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 00000000000..261e44d0649 --- /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 f604d4848e9..4d62dfffc3d 100644 --- a/src/fmgc/src/efis/EfisSymbols.ts +++ b/src/fmgc/src/efis/EfisSymbols.ts @@ -14,6 +14,8 @@ import { SegmentType } from '@fmgc/wtsdk'; import { distanceTo } from 'msfs-geo'; import { FlowEventSync } from '@shared/FlowEventSync'; import { LnavConfig } from '@fmgc/guidance/LnavConfig'; +import { getFlightPhaseManager } from '@fmgc/flightphase'; +import { FmgcFlightPhase } from '@shared/flightphase'; import { LegType, RunwaySurface, TurnDirection, VorType } from '../types/fstypes/FSEnums'; import { NearbyFacilities } from './NearbyFacilities'; @@ -48,6 +50,8 @@ export class EfisSymbols { private lastFpVersion; + private lastVnavDriverVersion: number = -1; + constructor(flightPlanManager: FlightPlanManager, guidanceController: GuidanceController) { this.flightPlanManager = flightPlanManager; this.guidanceController = guidanceController; @@ -91,6 +95,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 @@ -126,7 +132,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; } @@ -295,6 +309,11 @@ export class EfisSymbols { } } + const isInLatAutoControl = this.guidanceController.vnavDriver.isLatAutoControlActive(); + const waypointPredictions = this.guidanceController.vnavDriver.currentNavGeometryProfile?.waypointPredictions; + const isSelectedVerticalModeActive = this.guidanceController.vnavDriver.isSelectedVerticalModeActive(); + const flightPhase = getFlightPhaseManager().phase; + // TODO don't send the waypoint before active once FP sequencing is properly implemented // (currently sequences with guidance which is too early) // eslint-disable-next-line no-lone-blocks @@ -354,12 +373,24 @@ export class EfisSymbols { direction = wp.additionalData.course; } - if (wp.legAltitudeDescription > 0 && wp.legAltitudeDescription < 6) { - // TODO vnav to predict - type |= NdSymbolTypeFlags.ConstraintUnknown; + const isBehindAircraft = i < activeFp.activeWaypointIndex; + + if (isInLatAutoControl && !isBehindAircraft && wp.legAltitudeDescription > 0 && wp.legAltitudeDescription < 6) { + if (!isSelectedVerticalModeActive && shouldShowConstraintCircleInPhase(flightPhase, wp)) { + type |= NdSymbolTypeFlags.Constraint; + + const predictionAtWaypoint = waypointPredictions.get(i); + if (predictionAtWaypoint?.isAltitudeConstraintMet) { + type |= NdSymbolTypeFlags.MagentaColor; + } else if (predictionAtWaypoint) { + type |= NdSymbolTypeFlags.AmberColor; + } + } else if (i === activeFp.activeWaypointIndex) { + type |= NdSymbolTypeFlags.Constraint; + } } - if (efisOption === EfisOption.Constraints) { + if (!isBehindAircraft && efisOption === EfisOption.Constraints) { const descent = wp.constraintType === WaypointConstraintType.DES; switch (wp.legAltitudeDescription) { case 1: @@ -426,12 +457,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, type: pwp.efisSymbolFlag, + distanceFromAirplane: pwp.distanceFromStart, }); } @@ -579,3 +611,9 @@ export class EfisSymbols { } } } + +const shouldShowConstraintCircleInPhase = (phase: FmgcFlightPhase, waypoint: WayPoint) => ( + (phase === FmgcFlightPhase.Takeoff || phase === FmgcFlightPhase.Climb) && waypoint.additionalData.constraintType === WaypointConstraintType.CLB +) || ( + (phase === FmgcFlightPhase.Cruise || phase === FmgcFlightPhase.Descent || phase === FmgcFlightPhase.Approach) && waypoint.additionalData.constraintType === WaypointConstraintType.DES +); diff --git a/src/fmgc/src/flightplanning/FlightPlanManager.ts b/src/fmgc/src/flightplanning/FlightPlanManager.ts index 23df0d9f7f2..1a936471045 100644 --- a/src/fmgc/src/flightplanning/FlightPlanManager.ts +++ b/src/fmgc/src/flightplanning/FlightPlanManager.ts @@ -1973,4 +1973,18 @@ export class FlightPlanManager { return FlightArea.Enroute; } + + public tryAddOrUpdateCruiseStep(waypointIdent: string, toAltitude: Feet): boolean { + if (this._flightPlans[this._currentFlightPlanIndex].tryAddOrUpdateCruiseStep(waypointIdent, toAltitude)) { + this.updateFlightPlanVersion().catch(console.error); + return true; + } + + return false; + } + + public tryRemoveCruiseStep(waypointIndex: number): void { + this._flightPlans[this._currentFlightPlanIndex].tryRemoveCruiseStep(waypointIndex); + this.updateFlightPlanVersion().catch(console.error); + } } diff --git a/src/fmgc/src/flightplanning/ManagedFlightPlan.ts b/src/fmgc/src/flightplanning/ManagedFlightPlan.ts index 24a199964d9..30df74e4dc2 100644 --- a/src/fmgc/src/flightplanning/ManagedFlightPlan.ts +++ b/src/fmgc/src/flightplanning/ManagedFlightPlan.ts @@ -22,7 +22,7 @@ * SOFTWARE. */ -import { HoldData, WaypointStats } from '@fmgc/flightplanning/data/flightplan'; +import { HoldData, StepData, WaypointStats } from '@fmgc/flightplanning/data/flightplan'; import { AltitudeDescriptor, FixTypeFlags, LegType } from '../types/fstypes/FSEnums'; import { FlightPlanSegment, SegmentType } from './FlightPlanSegment'; import { LegsProcedure } from './LegsProcedure'; @@ -1191,6 +1191,8 @@ export class ManagedFlightPlan { } else { this.destinationAirfield.additionalData.annotation = approachName; } + + this.destinationAirfield.verticalAngle = lastLeg.verticalAngle } // Clear discontinuity before destination, if any @@ -1621,4 +1623,36 @@ export class ManagedFlightPlan { return false; } + + public tryAddOrUpdateCruiseStep(ident: string, toAltitude: Feet): boolean { + // TODO: Handle optimum steps + const waypointIndex = this.findWaypointIndexByIdent(ident); + const waypoint = this.getWaypoint(waypointIndex); + if (waypointIndex > 0) { + waypoint.additionalData.cruiseStep = { + distanceBeforeTermination: 0, + toAltitude, + waypointIndex: waypointIndex, + }; + + return true; + } + + return false; + } + + public tryRemoveCruiseStep(waypointIndex: number): boolean { + const waypoint = this.getWaypoint(waypointIndex); + + if (waypoint) { + waypoint.additionalData.cruiseStep = undefined; + return true; + } + + return false + } + + private findWaypointIndexByIdent(ident: string): number { + return this.waypoints.findIndex(waypoint => waypoint.ident === ident); + } } diff --git a/src/fmgc/src/flightplanning/data/flightplan.ts b/src/fmgc/src/flightplanning/data/flightplan.ts index 7a1e9c3ec33..7c709aa4e34 100644 --- a/src/fmgc/src/flightplanning/data/flightplan.ts +++ b/src/fmgc/src/flightplanning/data/flightplan.ts @@ -66,3 +66,11 @@ export enum HoldType { Database = 1, Pilot = 2, } + +export interface StepData { + distanceBeforeTermination: NauticalMiles, + + toAltitude: Feet, + + ident: string, +} diff --git a/src/fmgc/src/guidance/Geometry.ts b/src/fmgc/src/guidance/Geometry.ts index 851dcde8457..72e02427054 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/Guidable.ts b/src/fmgc/src/guidance/Guidable.ts index 4e0ef3d8f2a..60b2a8f4c05 100644 --- a/src/fmgc/src/guidance/Guidable.ts +++ b/src/fmgc/src/guidance/Guidable.ts @@ -107,12 +107,15 @@ export abstract class Guidable { * @param distanceBeforeTerminator */ getPseudoWaypointLocation(distanceBeforeTerminator: NauticalMiles): Coordinates | undefined { + let accumulator = 0; for (const vector of [...this.predictedPath].reverse()) { const length = pathVectorLength(vector); - if (length > distanceBeforeTerminator) { - return pathVectorPoint(vector, distanceBeforeTerminator); + if (accumulator + length > distanceBeforeTerminator) { + return pathVectorPoint(vector, distanceBeforeTerminator - accumulator); } + + accumulator += length; } return undefined; } diff --git a/src/fmgc/src/guidance/GuidanceController.ts b/src/fmgc/src/guidance/GuidanceController.ts index 84664d26e7f..addb8646ca8 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,37 @@ 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, + getOriginTransitionAltitude(): Feet | undefined, + 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, + getApproachQnh(): number, + getApproachTemperature(): number, +} + export class GuidanceController { flightPlanManager: FlightPlanManager; @@ -68,8 +105,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 +204,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(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 +270,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 +300,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 +314,8 @@ export class GuidanceController { } } + this.atmosphericConditions.update(); + try { this.updateMrpState(); } catch (e) { @@ -413,4 +476,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 65% rename from src/fmgc/src/guidance/PsuedoWaypoint.ts rename to src/fmgc/src/guidance/PseudoWaypoint.ts index 2501a818170..5d82a6b8432 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 @@ -18,7 +17,6 @@ export enum PseudoWaypointSequencingAction { * Used for approach phase auto-engagement condition eg. (DECEL) */ APPROACH_PHASE_AUTO_ENGAGE, - } export interface PseudoWaypoint { @@ -54,14 +52,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`) */ - stats: WaypointStats, + flightPlanInfo?: PseudoWaypointFlightPlanInfo + + /** + * Determines whether a PWP should show up as a symbol on the ND + */ + 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 243505f5557..91e11237d7c 100644 --- a/src/fmgc/src/guidance/lnav/PseudoWaypoints.ts +++ b/src/fmgc/src/guidance/lnav/PseudoWaypoints.ts @@ -4,26 +4,86 @@ // 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 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, +]); +const isCheckpointForMcduPwp = (checkpoint: VerticalCheckpoint) => CHECKPOINTS_TO_PUT_IN_MCDU.has(checkpoint.reason); + +const CHECKPOINTS_TO_DRAW_ON_ND = new Set([ + VerticalCheckpointReason.TopOfClimb, + VerticalCheckpointReason.LevelOffForClimbConstraint, + VerticalCheckpointReason.ContinueClimb, + VerticalCheckpointReason.CrossingFcuAltitudeClimb, + VerticalCheckpointReason.TopOfDescent, + VerticalCheckpointReason.CrossingFcuAltitudeDescent, + VerticalCheckpointReason.ContinueDescent, + VerticalCheckpointReason.ContinueDescentArmed, + VerticalCheckpointReason.LevelOffForDescentConstraint, + VerticalCheckpointReason.InterceptDescentProfileManaged, + VerticalCheckpointReason.InterceptDescentProfileSelected, + VerticalCheckpointReason.Decel, + VerticalCheckpointReason.Flaps1, + VerticalCheckpointReason.Flaps2, +]); +const isCheckpointForNdPwp = (checkpoint: VerticalCheckpoint) => CHECKPOINTS_TO_DRAW_ON_ND.has(checkpoint.reason); + +const CHECKPOINT_REASONS_BEFORE_FCU_ALT_FOR_PWP: VerticalCheckpointReason[] = [ + VerticalCheckpointReason.LevelOffForClimbConstraint, + VerticalCheckpointReason.ContinueClimb, + VerticalCheckpointReason.CrossingClimbSpeedLimit, + VerticalCheckpointReason.CrossingFcuAltitudeClimb, +]; + +const CDA_CHECKPOINT_FOR_PWP: Set = new Set([ + VerticalCheckpointReason.Flaps1, + VerticalCheckpointReason.Flaps2, +]); +const isCheckpointForCdaPwp = (checkpoint: VerticalCheckpoint) => CDA_CHECKPOINT_FOR_PWP.has(checkpoint.reason); + export class PseudoWaypoints implements GuidanceComponent { pseudoWaypoints: PseudoWaypoint[] = []; - constructor(private guidanceController: GuidanceController) { } + constructor(private guidanceController: GuidanceController, private atmosphericConditions: AtmosphericConditions) { } acceptVerticalProfile() { if (DEBUG) { @@ -42,106 +102,108 @@ export class PseudoWaypoints implements GuidanceComponent { private recompute() { const geometry = this.guidanceController.activeGeometry; const wptCount = this.guidanceController.flightPlanManager.getWaypointsCount(); - const haveApproach = !!this.guidanceController.vnavDriver.currentApproachProfile; - if (!geometry || geometry.legs.size < 1) { + const navGeometryProfile = this.guidanceController.vnavDriver.currentNavGeometryProfile; + if (!geometry || geometry.legs.size < 1 || !navGeometryProfile.isReadyToDisplay) { this.pseudoWaypoints.length = 0; return; } + const ndPseudoWaypointCandidates = this.guidanceController.vnavDriver.currentNdGeometryProfile?.isReadyToDisplay + ? this.guidanceController.vnavDriver.currentNdGeometryProfile.checkpoints.filter(isCheckpointForNdPwp) + : []; + const newPseudoWaypoints: PseudoWaypoint[] = []; + const totalDistance = navGeometryProfile.totalFlightPlanDistance; - if (VnavConfig.VNAV_EMIT_TOD) { - const tod = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, this.guidanceController.vnavDriver.currentDescentProfile.tod, DEBUG && PWP_IDENT_TOD); + const shouldEmitCdaPwp = VnavConfig.VNAV_DESCENT_MODE === VnavDescentMode.CDA && VnavConfig.VNAV_EMIT_CDA_FLAP_PWP; - if (tod) { - const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = tod; + // We do this so we only draw the first of each waypoint type + const waypointsLeftToDraw = new Set([...CHECKPOINTS_TO_PUT_IN_MCDU, ...CHECKPOINTS_TO_DRAW_ON_ND]); - 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), - }); + for (const checkpoint of [...navGeometryProfile.checkpoints.filter(isCheckpointForMcduPwp), ...ndPseudoWaypointCandidates]) { + if (!waypointsLeftToDraw.has(checkpoint.reason) || (!shouldEmitCdaPwp && isCheckpointForCdaPwp(checkpoint))) { + continue; } - } - if (VnavConfig.VNAV_EMIT_DECEL && haveApproach) { - 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), - }); + const pwp = this.createPseudoWaypointFromVerticalCheckpoint(geometry, wptCount, totalDistance, checkpoint); + if (pwp) { + newPseudoWaypoints.push(pwp); } - - // 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), - // }); - // } - // } } - if (VnavConfig.VNAV_DESCENT_MODE === VnavDescentMode.CDA && VnavConfig.VNAV_EMIT_CDA_FLAP_PWP && haveApproach) { - const flap1 = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, this.guidanceController.vnavDriver.currentApproachProfile.flap1, DEBUG && PWP_IDENT_FLAP1); + // Speed Changes + const firstSpeedChange = this.guidanceController.vnavDriver.findNextSpeedChange(); - if (flap1) { - const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = flap1; + if (Number.isFinite(firstSpeedChange)) { + let [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = [undefined, undefined, undefined]; + if (this.guidanceController.vnavDriver.isLatAutoControlActive()) { + 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; + } } - const flap2 = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, this.guidanceController.vnavDriver.currentApproachProfile.flap2, DEBUG && PWP_IDENT_FLAP2); + 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.isLatAutoControlActive() || !prediction) { + continue; + } - if (flap2) { - const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = flap2; + const position = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, totalDistance - prediction.distanceFromStart, `TIME ${time}`); + + if (position) { + const [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = position; + + 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; } @@ -158,6 +220,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.isLatAutoControlActive(); // 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 @@ -166,7 +229,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) ) { @@ -209,25 +273,15 @@ export class PseudoWaypoints implements GuidanceComponent { } /** - * 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,14 +289,19 @@ export class PseudoWaypoints implements GuidanceComponent { wptCount: number, distanceFromEnd: NauticalMiles, debugString?: string, + forcePlacementInDiscontinuity: boolean = false, ): [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`); } @@ -253,6 +312,23 @@ 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) { + if (nextLeg instanceof XFLeg) { + distanceInDiscontinuity = Avionics.Utils.computeGreatCircleDistance(leg.fix.infos.coordinates, nextLeg.fix.infos.coordinates); + } else if (!nextLeg && leg.fix.additionalData.distanceToEnd > 0) { + // This is a filthy hack + distanceInDiscontinuity = leg.fix.additionalData.distanceToEnd; + } + } 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); @@ -275,6 +351,16 @@ export class PseudoWaypoints implements GuidanceComponent { } if (accumulator > distanceFromEnd) { + if (distanceInDiscontinuity > 0 && 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 PWP unless we force placement. In this case, we place it on the termination + if (forcePlacementInDiscontinuity && leg instanceof XFLeg) { + return [leg.fix.infos.coordinates, distanceFromEnd - (accumulator - totalLegPathLength), i]; + } + + return undefined; + } + const distanceFromEndOfLeg = distanceFromEnd - (accumulator - totalLegPathLength); let lla; @@ -311,6 +397,10 @@ export class PseudoWaypoints implements GuidanceComponent { return [lla, distanceFromEndOfLeg, i]; } + if (VnavConfig.DEBUG_PROFILE) { + console.warn(`[FMS/PseudoWaypoints] Tried to place PWP ${debugString} on ${leg.repr}, but failed`); + } + return undefined; } } @@ -321,4 +411,256 @@ 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]; + // We want the decel point and T/D to be drawn along the track line even if not in NAV mode + if (this.guidanceController.vnavDriver.isLatAutoControlActive() || isCheckpointForMcduPwp(checkpoint)) { + // TODO: Pass in `shouldForcePlacementOnFlightplan` + const pwp = PseudoWaypoints.pointFromEndOfPath(geometry, wptCount, totalDistance - checkpoint?.distanceFromStart, checkpoint.reason); + + if (pwp) { + [efisSymbolLla, distanceFromLegTermination, alongLegIndex] = pwp; + } else if (VnavConfig.DEBUG_PROFILE) { + 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, + alongLegIndex, + distanceFromLegTermination, + efisSymbolFlag: NdSymbolTypeFlags.PwpStartOfClimb, + efisSymbolLla, + distanceFromStart: checkpoint.distanceFromStart, + displayedOnMcdu: true, + flightPlanInfo: this.formatFlightPlanInfo(checkpoint, geometry, alongLegIndex, distanceFromLegTermination), + displayedOnNd: this.guidanceController.vnavDriver.isLatAutoControlActive(), + }; + case VerticalCheckpointReason.StepDescent: + return { + ident: PWP_IDENT_STEP_DESCENT, + alongLegIndex, + distanceFromLegTermination, + efisSymbolFlag: NdSymbolTypeFlags.PwpTopOfDescent, + efisSymbolLla, + distanceFromStart: checkpoint.distanceFromStart, + displayedOnMcdu: true, + flightPlanInfo: this.formatFlightPlanInfo(checkpoint, geometry, alongLegIndex, distanceFromLegTermination), + displayedOnNd: this.guidanceController.vnavDriver.isLatAutoControlActive(), + }; + case VerticalCheckpointReason.ContinueDescent: + return { + ident: PWP_IDENT_TOD, + alongLegIndex, + distanceFromLegTermination, + efisSymbolFlag: NdSymbolTypeFlags.PwpTopOfDescent, + efisSymbolLla, + distanceFromStart: checkpoint.distanceFromStart, + displayedOnMcdu: false, + displayedOnNd: true, + }; + case VerticalCheckpointReason.ContinueDescentArmed: + return { + ident: PWP_IDENT_TOD, + alongLegIndex, + distanceFromLegTermination, + efisSymbolFlag: NdSymbolTypeFlags.PwpTopOfDescent | NdSymbolTypeFlags.CyanColor, + efisSymbolLla, + distanceFromStart: checkpoint.distanceFromStart, + displayedOnMcdu: false, + 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: this.guidanceController.vnavDriver.isLatAutoControlActive(), + }; + 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 + | (Simplane.getAutoPilotAirspeedManaged() && this.guidanceController.vnavDriver.isLatAutoControlActive() ? NdSymbolTypeFlags.MagentaColor : 0), + 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 cc5ccc81213..51513d25412 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 7007d50fdd4..3a6d6480923 100644 --- a/src/fmgc/src/guidance/lnav/legs/index.ts +++ b/src/fmgc/src/guidance/lnav/legs/index.ts @@ -33,6 +33,8 @@ export interface SpeedConstraint { speed: Knots, } +export type PathAngleConstraint = Degrees; + export abstract class FXLeg extends Leg { from: WayPoint; } @@ -44,6 +46,7 @@ export function getAltitudeConstraintFromWaypoint(wp: WayPoint): AltitudeConstra ac.altitude2 = undefined; switch (wp.legAltitudeDescription) { case 1: + case 6: ac.type = AltitudeConstraintType.at; break; case 2: @@ -53,6 +56,7 @@ export function getAltitudeConstraintFromWaypoint(wp: WayPoint): AltitudeConstra ac.type = AltitudeConstraintType.atOrBelow; break; case 4: + case 7: ac.type = AltitudeConstraintType.range; ac.altitude2 = wp.legAltitude2; break; @@ -67,13 +71,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, @@ -110,6 +119,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 */ @@ -131,11 +145,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 297eefcccfa..b05bc5d67e6 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/lnav/transitions/FixedRadiusTransition.ts b/src/fmgc/src/guidance/lnav/transitions/FixedRadiusTransition.ts index e19edb3fa9c..82de22379c2 100644 --- a/src/fmgc/src/guidance/lnav/transitions/FixedRadiusTransition.ts +++ b/src/fmgc/src/guidance/lnav/transitions/FixedRadiusTransition.ts @@ -45,7 +45,7 @@ export class FixedRadiusTransition extends Transition { private centre: Coordinates | undefined = undefined; - private revertTo: PathCaptureTransition | undefined = undefined; + public revertTo: PathCaptureTransition | undefined = undefined; constructor( public previousLeg: PrevLeg, // FIXME temporary diff --git a/src/fmgc/src/guidance/lnav/transitions/PathCaptureTransition.ts b/src/fmgc/src/guidance/lnav/transitions/PathCaptureTransition.ts index 31bb3f22a97..fcd3df4c214 100644 --- a/src/fmgc/src/guidance/lnav/transitions/PathCaptureTransition.ts +++ b/src/fmgc/src/guidance/lnav/transitions/PathCaptureTransition.ts @@ -13,7 +13,7 @@ import { Transition } from '@fmgc/guidance/lnav/Transition'; import { GuidanceParameters } from '@fmgc/guidance/ControlLaws'; import { Coordinates } from '@fmgc/flightplanning/data/geo'; import { Geo } from '@fmgc/utils/Geo'; -import { PathVector, PathVectorType } from '@fmgc/guidance/lnav/PathVector'; +import { PathVector, pathVectorLength, PathVectorType } from '@fmgc/guidance/lnav/PathVector'; import { CourseChange } from '@fmgc/guidance/lnav/transitions/utilss/CourseChange'; import { Constants } from '@shared/Constants'; import { LnavConfig } from '@fmgc/guidance/LnavConfig'; @@ -483,4 +483,27 @@ export class PathCaptureTransition extends Transition { get repr(): string { return `PATH CAPTURE(${this.previousLeg.repr} TO ${this.nextLeg.repr})`; } + + // This is for VNAV to estimate the amount of track miles left + getActualDistanceToGo(ppos: LatLongData, trueTrack: number): NauticalMiles { + let dtg = 0; + + for (const path of this.predictedPath) { + if ('centrePoint' in path) { + // Arc + const turnSign = this.computedTurnDirection === TurnDirection.Left ? -1 : 1; + let trackAngleError = this.computedTargetTrack - trueTrack; + if (turnSign !== Math.sign(trackAngleError)) { + trackAngleError += turnSign * 360; + } + + dtg += pathVectorLength(path) * trackAngleError / path.sweepAngle; + } else if ('endPoint' in path) { + // Line + dtg += Math.min(pathVectorLength(path), distanceTo(path.endPoint, ppos)); + } + } + + return dtg; + } } diff --git a/src/fmgc/src/guidance/vnav/AtmosphericConditions.ts b/src/fmgc/src/guidance/vnav/AtmosphericConditions.ts index c8767c74bfe..2775bb09bd6 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: Celsius; - 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 00000000000..4a0dd045b34 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/ConstraintReader.ts @@ -0,0 +1,212 @@ +import { GeographicCruiseStep, DescentAltitudeConstraint, MaxAltitudeConstraint, MaxSpeedConstraint } from '@fmgc/guidance/vnav/profile/NavGeometryProfile'; +import { Geometry } from '@fmgc/guidance/Geometry'; +import { AltitudeConstraintType, getAltitudeConstraintFromWaypoint, getSpeedConstraintFromWaypoint } from '@fmgc/guidance/lnav/legs'; +import { FlightPlans, WaypointConstraintType } from '@fmgc/flightplanning/FlightPlanManager'; +import { VMLeg } from '@fmgc/guidance/lnav/legs/VM'; +import { PathCaptureTransition } from '@fmgc/guidance/lnav/transitions/PathCaptureTransition'; +import { FixedRadiusTransition } from '@fmgc/guidance/lnav/transitions/FixedRadiusTransition'; +import { GuidanceController } from '@fmgc/guidance/GuidanceController'; +import { MathUtils } from '@shared/MathUtils'; +import { FixTypeFlags } from '@fmgc/types/fstypes/FSEnums'; + +export class ConstraintReader { + public climbAlitudeConstraints: MaxAltitudeConstraint[] = []; + + public descentAltitudeConstraints: DescentAltitudeConstraint[] = []; + + public climbSpeedConstraints: MaxSpeedConstraint[] = []; + + public descentSpeedConstraints: MaxSpeedConstraint[] = []; + + public cruiseSteps: GeographicCruiseStep[] = []; + + public totalFlightPlanDistance = 0; + + public distanceToEnd: NauticalMiles = 0; + + public finalDescentAngle = 3; + + public fafDistanceToEnd = 1000 / Math.tan(3 * MathUtils.DEGREES_TO_RADIANS) / 6076.12; + + public get distanceToPresentPosition(): NauticalMiles { + return this.totalFlightPlanDistance - this.distanceToEnd; + } + + constructor(private guidanceController: GuidanceController) { + this.reset(); + } + + updateGeometry(geometry: Geometry, ppos: LatLongAlt) { + this.reset(); + this.updateDistancesToEnd(geometry); + + const fpm = this.guidanceController.flightPlanManager; + for (let i = 0; i < fpm.getWaypointsCount(FlightPlans.Active); i++) { + const waypoint = fpm.getWaypoint(i, FlightPlans.Active); + + if (waypoint.additionalData.cruiseStep) { + if (i >= fpm.getActiveWaypointIndex()) { + const { waypointIndex, toAltitude, distanceBeforeTermination } = waypoint.additionalData.cruiseStep; + + this.cruiseSteps.push({ + distanceFromStart: this.totalFlightPlanDistance - waypoint.additionalData.distanceToEnd - distanceBeforeTermination, + toAltitude, + waypointIndex, + isIgnored: false, + }); + } else { + // We've already passed the waypoint + waypoint.additionalData.cruiseStep = undefined; + SimVar.SetSimVarValue('L:A32NX_FM_VNAV_TRIGGER_STEP_DELETED', 'boolean', true); + } + } + + const altConstraint = getAltitudeConstraintFromWaypoint(waypoint); + const speedConstraint = getSpeedConstraintFromWaypoint(waypoint); + + if (waypoint.additionalData.constraintType === WaypointConstraintType.CLB) { + if (altConstraint && altConstraint.type !== AltitudeConstraintType.atOrAbove) { + this.climbAlitudeConstraints.push({ + distanceFromStart: this.totalFlightPlanDistance - waypoint.additionalData.distanceToEnd, + maxAltitude: altConstraint.altitude1, + }); + } + + if (speedConstraint && waypoint.speedConstraint > 100) { + this.climbSpeedConstraints.push({ + distanceFromStart: this.totalFlightPlanDistance - waypoint.additionalData.distanceToEnd, + maxSpeed: speedConstraint.speed, + }); + } + } else if (waypoint.additionalData.constraintType === WaypointConstraintType.DES) { + if (altConstraint) { + this.descentAltitudeConstraints.push({ + distanceFromStart: this.totalFlightPlanDistance - waypoint.additionalData.distanceToEnd, + constraint: altConstraint, + }); + } + + if (speedConstraint && waypoint.speedConstraint > 100) { + this.descentSpeedConstraints.push({ + distanceFromStart: this.totalFlightPlanDistance - waypoint.additionalData.distanceToEnd, + maxSpeed: speedConstraint.speed, + }); + } + } + + if (i === fpm.getDestinationIndex() && waypoint.verticalAngle) { + this.finalDescentAngle = waypoint.verticalAngle; + } + + if ((waypoint.additionalData.fixTypeFlags & FixTypeFlags.FAF) > 0) { + this.fafDistanceToEnd = waypoint.additionalData.distanceToEnd; + } + } + + this.updateDistanceToEnd(ppos); + } + + public updateDistanceToEnd(ppos: LatLongAlt) { + const geometry = this.guidanceController.activeGeometry; + const activeLegIndex = this.guidanceController.activeLegIndex; + const activeTransIndex = this.guidanceController.activeTransIndex; + const fpm = this.guidanceController.flightPlanManager; + + const leg = geometry.legs.get(activeLegIndex); + if (!leg || leg.isNull) { + return; + } + + const nextWaypoint = fpm.getWaypoint(activeLegIndex, FlightPlans.Active); + + const inboundTransition = geometry.transitions.get(activeLegIndex - 1); + const outboundTransition = geometry.transitions.get(activeLegIndex); + + const [_, legDistance, outboundLength] = Geometry.completeLegPathLengths( + leg, (inboundTransition?.isNull || !inboundTransition?.isComputed) ? null : inboundTransition, outboundTransition, + ); + + if (activeTransIndex < 0) { + const distanceToGo = leg instanceof VMLeg + ? Avionics.Utils.computeGreatCircleDistance(ppos, nextWaypoint.infos.coordinates) + : leg.getDistanceToGo(ppos); + + this.distanceToEnd = distanceToGo + outboundLength + (nextWaypoint.additionalData.distanceToEnd ?? 0); + } else if (activeTransIndex === activeLegIndex) { + // On an outbound transition + // We subtract `outboundLength` because getDistanceToGo will include the entire distance while we only want the part that's on this leg. + // For a FixedRadiusTransition, there's also a part on the next leg. + this.distanceToEnd = outboundTransition.getDistanceToGo(ppos) - outboundLength + (nextWaypoint.additionalData.distanceToEnd ?? 0); + } else if (activeTransIndex === activeLegIndex - 1) { + // On an inbound transition + const trueTrack = SimVar.GetSimVarValue('GPS GROUND TRUE TRACK', 'degree'); + + let transitionDistanceToGo = inboundTransition.getDistanceToGo(ppos); + + if (inboundTransition instanceof PathCaptureTransition) { + transitionDistanceToGo = inboundTransition.getActualDistanceToGo(ppos, trueTrack); + } else if (inboundTransition instanceof FixedRadiusTransition && inboundTransition.isReverted) { + transitionDistanceToGo = inboundTransition.revertTo.getActualDistanceToGo(ppos, trueTrack); + } + + this.distanceToEnd = transitionDistanceToGo + legDistance + outboundLength + (nextWaypoint.additionalData.distanceToEnd ?? 0); + } else { + console.error(`[FMS/VNAV] Unexpected transition index (legIndex: ${activeLegIndex}, transIndex: ${activeTransIndex})`); + } + + SimVar.SetSimVarValue('L:A32NX_FM_VNAV_DEBUG_DISTANCE_TO_END', 'number', this.distanceToEnd); + SimVar.SetSimVarValue('L:A32NX_FM_VNAV_DEBUG_DISTANCE_FROM_START', 'number', this.distanceToPresentPosition); + } + + reset() { + this.climbAlitudeConstraints = []; + this.descentAltitudeConstraints = []; + this.climbSpeedConstraints = []; + this.descentSpeedConstraints = []; + this.cruiseSteps = []; + + this.totalFlightPlanDistance = 0; + this.distanceToEnd = 0; + this.finalDescentAngle = 3; + this.fafDistanceToEnd = 1000 / Math.tan(3 * MathUtils.DEGREES_TO_RADIANS) / 6076.12; + } + + private updateDistancesToEnd(geometry: Geometry) { + const { legs, transitions } = geometry; + const fpm = this.guidanceController.flightPlanManager; + + this.totalFlightPlanDistance = 0; + + for (let i = fpm.getWaypointsCount(FlightPlans.Active) - 1; i > fpm.getActiveWaypointIndex() - 1 && i >= 0; i--) { + const leg = legs.get(i); + const waypoint = fpm.getWaypoint(i, FlightPlans.Active); + const nextWaypoint = fpm.getWaypoint(i + 1, FlightPlans.Active); + + if (!leg || leg.isNull) { + continue; + } + + if (waypoint.endsInDiscontinuity) { + const startingPointOfDisco = waypoint.discontinuityCanBeCleared + ? waypoint + : fpm.getWaypoint(i - 1, FlightPlans.Active); // MANUAL + + this.totalFlightPlanDistance += Avionics.Utils.computeGreatCircleDistance(startingPointOfDisco.infos.coordinates, nextWaypoint.infos.coordinates); + } + + waypoint.additionalData.distanceToEnd = this.totalFlightPlanDistance; + + const inboundTransition = transitions.get(i - 1); + const outboundTransition = 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; + + this.totalFlightPlanDistance += totalLegLength; + } + } +} diff --git a/src/fmgc/src/guidance/vnav/CruiseToDescentCoordinator.ts b/src/fmgc/src/guidance/vnav/CruiseToDescentCoordinator.ts new file mode 100644 index 00000000000..f2c47558fe8 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/CruiseToDescentCoordinator.ts @@ -0,0 +1,157 @@ +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'; +import { ProfileInterceptCalculator } from '@fmgc/guidance/vnav/descent/ProfileInterceptCalculator'; + +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; + } + + let 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, descentWinds, 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(profile)); + + if (descentPath.lastCheckpoint.reason !== VerticalCheckpointReason.TopOfDescent) { + console.error('[FMS/VNAV] Approach path did not end in T/D. Discarding descent profile.'); + return; + } + + if (descentPath.lastCheckpoint.distanceFromStart < startingPoint.distanceFromStart) { + // Check if plane is past T/D. + if (startingPoint.reason === VerticalCheckpointReason.PresentPosition) { + // 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; + } if (startingPoint.reason === VerticalCheckpointReason.TopOfClimb) { + // Flight plan too short + const climbDescentInterceptDistance = ProfileInterceptCalculator.calculateIntercept(profile.checkpoints, descentPath.checkpoints); + + // If we somehow don't find an intercept between climb and descent path, just build the cruise path until end of the path + if (!climbDescentInterceptDistance) { + cruisePath = this.cruisePathBuilder.computeCruisePath( + profile, startingPoint, descentPath.at(0).distanceFromStart, stepClimbStrategy, stepDescentStrategy, speedProfile, cruiseWinds, + ); + + console.error('[FMS/VNAV] Edge case: Flight plan too short. However, no intercept between climb and descent path.'); + profile.checkpoints.push(...cruisePath.get()); + + return; + } + + // If there is an intercept, place the T/D wherever we need it + const combinedTopOfClimbTopOfDescent = profile.addInterpolatedCheckpoint(climbDescentInterceptDistance, { reason: VerticalCheckpointReason.TopOfClimb }); + const savedTopOfDescent = descentPath.lastCheckpoint; + descentPath.checkpoints = descentPath.checkpoints.filter((checkpoint) => checkpoint.distanceFromStart >= combinedTopOfClimbTopOfDescent.distanceFromStart); + // TODO: We should interpolate this point along the descent path, so fuel and time are correct + descentPath.push({ ...savedTopOfDescent, distanceFromStart: combinedTopOfClimbTopOfDescent.distanceFromStart, altitude: combinedTopOfClimbTopOfDescent.altitude }); + + startingPoint = combinedTopOfClimbTopOfDescent; + } + } + + 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 = profile.checkpoints.filter((checkpoint) => checkpoint.distanceFromStart <= startingPoint.distanceFromStart); + + 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 cb26c61f8a7..6e180b018ba 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 e22d15458c0..a89bc5151ce 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 42029c39b42..6077d4eb589 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, @@ -225,41 +415,31 @@ export class Predictions { tropoAltitude: number, gearExtended = false, flapConfig = FlapConf.CLEAN, + speedbrakesExtended = false, minimumAbsoluteAcceleration?: number, + perfFactorPercent = 0, ): 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; @@ -272,82 +452,55 @@ export class Predictions { const correctedThrust = EngineModel.tableInterpolation(EngineModel.table1506, correctedN1, averageMach) * 2 * EngineModel.maxThrust; const correctedFuelFlow = EngineModel.getCorrectedFuelFlow(correctedN1, averageMach, initialAltitude) * 2; const thrust = EngineModel.getUncorrectedThrust(correctedThrust, delta2); // in lbf - const fuelFlow = EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2); // in lbs/hour + const fuelFlow = EngineModel.getUncorrectedFuelFlow(correctedFuelFlow, delta2, theta2) * (1 + perfFactorPercent / 100); // in lbs/hour const weightEstimate = zeroFuelWeight + initialFuelWeight; - const pathAngleRadians = flightPahAngle * MathUtils.DEGREES_TO_RADIANS; - - let error; - let verticalSpeed; - let stepTime; - let distanceTraveled; - let fuelBurned; - let finalAltitude; + let pathAngleRadians = flightPathAngle * MathUtils.DEGREES_TO_RADIANS; + 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; let iterations = 0; do { // Calculate the available gradient - const drag = FlightModel.getDrag(lift, averageMach, delta, false, gearExtended, flapConfig); + const drag = FlightModel.getDrag(lift, averageMach, delta, speedbrakesExtended, gearExtended, flapConfig); const availableGradient = FlightModel.getAvailableGradient(thrust, drag, weightEstimate); + pathAngleRadians = flightPathAngle * MathUtils.DEGREES_TO_RADIANS; if (Math.abs(availableGradient) < Math.abs(pathAngleRadians)) { - if (DEBUG) { - console.warn('[FMS/VNAV/ConstantSlopeSegment] Desired path angle is greater than available gradient.'); - } error = VnavStepError.AVAILABLE_GRADIENT_INSUFFICIENT; + // Save the achievable gradient here, so it can be used by the caller + pathAngleRadians = availableGradient; + + break; } - const acceleration = FlightModel.accelerationForGradient( - availableGradient, - pathAngleRadians, - 9.81, - ); + const acceleration = FlightModel.accelerationForGradient(availableGradient, pathAngleRadians, FlightModel.gravityConstKNS); // in kts/s - // 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) { - if (DEBUG) { - console.warn('[FMS/VNAV/ConstantSlopeSegment] Minimum absolute acceleration not achieved with given desired path angle.'); - } + if (Math.abs(acceleration) < minimumAbsoluteAcceleration) { error = VnavStepError.TOO_LOW_DECELERATION; - } - - stepTime = Math.abs(finalTas - initialTas) / Math.abs(accelerationKNS); - distanceTraveled = (stepTime / 3600) * averageTas; + break; + } - verticalSpeed = 101.268 * averageTas * 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; - // const endStepWeight = zeroFuelWeight + (initialFuelWeight - fuelBurned); <- not really needed + stepTime = (finalTas - initialTas) / acceleration; // in seconds + distanceTraveled = (stepTime / 3600) * (averageTas - headwindAtInitialAltitude); + finalAltitude = initialAltitude + 6076.12 * distanceTraveled * Math.tan(pathAngleRadians); + verticalSpeed = Math.abs(stepTime) < 1e-12 ? 0 : 60 * (finalAltitude - initialAltitude) / stepTime; // in feet per minute + fuelBurned = (fuelFlow / 3600) * stepTime; // Adjust variables for better accuracy next iteration previousMidStepWeight = midStepWeight; 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 +508,10 @@ export class Predictions { timeElapsed: stepTime, distanceTraveled, fuelBurned, + initialAltitude, finalAltitude, error, + speed: finalCAS, }; } @@ -418,6 +573,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 +585,11 @@ export class Predictions { zeroFuelWeight: number, initialFuelWeight: number, isaDev: number, + headwind: number, tropoAltitude: number, + gearExtended: boolean, + flapConfig: FlapConf, + speedbrakesExtended: boolean, ): StepResults { const distanceInFeet = distance * 6076.12; const fpaRadians = Math.atan((finalAltitude - initialAltitude) / distanceInFeet); @@ -441,13 +602,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 +618,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, speedbrakesExtended, gearExtended, flapConfig); const accelFactor = Common.getAccelerationFactor(mach, midStepAltitude, isaDev, midStepAltitude > tropoAltitude, accelFactorMode); thrust = FlightModel.getThrustFromConstantPathAngleCoefficients( @@ -479,8 +639,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 +649,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 +665,448 @@ 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, + speed: econCAS, + }; + } + + 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; + // If above crossover altitude, use econMach + if (initialMach > econMach) { + initialMach = econMach; + initialTas = Common.machToTAS(initialMach, theta); + } 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 acceleration = FlightModel.accelerationForGradient(availableGradient, pathAngle, FlightModel.gravityConstKNS); // kts/s + + 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; + // If above crossover altitude, use econMach + if (initialMach > econMach) { + initialMach = econMach; + initialTas = Common.machToTAS(initialMach, theta); + } 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 acceleration = FlightModel.accelerationForGradient(availableGradient, pathAngle, FlightModel.gravityConstKNS); // kts/s + + 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; + // If above crossover altitude, use econMach + if (initialMach > econMach) { + initialMach = econMach; + initialTas = Common.machToTAS(initialMach, theta); + } 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. + // I don't know what this means for the path angle, so this value was found just by trial and error. + pathAngle = availableGradient * 0.3; + const acceleration = FlightModel.accelerationForGradient(availableGradient, pathAngle, FlightModel.gravityConstKNS); // kts/s + + 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/VerticalProfileComputationParameters.ts b/src/fmgc/src/guidance/vnav/VerticalProfileComputationParameters.ts new file mode 100644 index 00000000000..3c6ba945291 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/VerticalProfileComputationParameters.ts @@ -0,0 +1,129 @@ +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, + + managedClimbSpeed: Knots, + managedClimbSpeedMach: Mach, + managedCruiseSpeed: Knots, + managedCruiseSpeedMach: Mach, + managedDescentSpeed: Knots, + managedDescentSpeedMach: Mach, + + zeroFuelWeight: Pounds, + fuelOnBoard: Pounds, + v2Speed: Knots, + tropoPause: Feet, + perfFactor: number, + originAirfieldElevation: Feet, + destinationAirfieldElevation: Feet, + accelerationAltitude: Feet, + thrustReductionAltitude: Feet, + originTransitionAltitude?: Feet, + cruiseAltitude: Feet, + climbSpeedLimit: SpeedLimit, + descentSpeedLimit: SpeedLimit, + flightPhase: FmgcFlightPhase, + preselectedClbSpeed: Knots, + takeoffFlapsSetting?: FlapConf + + approachQnh: Millibar, + approachTemperature: Celsius, + 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'), + + managedClimbSpeed: this.fmgc.getManagedClimbSpeed(), + managedClimbSpeedMach: this.fmgc.getManagedClimbSpeedMach(), + managedCruiseSpeed: this.fmgc.getManagedCruiseSpeed(), + managedCruiseSpeedMach: this.fmgc.getManagedCruiseSpeedMach(), + managedDescentSpeed: this.fmgc.getManagedDescentSpeed(), + managedDescentSpeedMach: this.fmgc.getManagedDescentSpeedMach(), + + zeroFuelWeight: this.fmgc.getZeroFuelWeight(), + fuelOnBoard: this.fmgc.getFOB() * Constants.TONS_TO_POUNDS, + v2Speed: this.fmgc.getV2Speed(), + tropoPause: this.fmgc.getTropoPause(), + 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(), + originTransitionAltitude: this.fmgc.getOriginTransitionAltitude(), + 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(), + + approachQnh: this.fmgc.getApproachQnh(), + approachTemperature: this.fmgc.getApproachTemperature(), + 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 6332c3ea7ff..5a2430fc535 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 64ff323258b..f9aa5389eab 100644 --- a/src/fmgc/src/guidance/vnav/VnavDriver.ts +++ b/src/fmgc/src/guidance/vnav/VnavDriver.ts @@ -1,29 +1,64 @@ // 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 { FlightPlans } from '@fmgc/flightplanning/FlightPlanManager'; +import { FlightPlanManager } from '@fmgc/flightplanning/FlightPlanManager'; +import { VerticalMode, ArmedLateralMode, ArmedVerticalMode, isArmed, LateralMode } from '@shared/autopilot'; +import { PseudoWaypointFlightPlanInfo } from '@fmgc/guidance/PseudoWaypoint'; +import { VerticalProfileComputationParameters, 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, ManagedSpeedType } from '@fmgc/guidance/vnav/climb/SpeedProfile'; +import { SelectedGeometryProfile } from '@fmgc/guidance/vnav/profile/SelectedGeometryProfile'; +import { BaseGeometryProfile } from '@fmgc/guidance/vnav/profile/BaseGeometryProfile'; +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 { AltitudeConstraint, LegMetadata, SpeedConstraint } from '@fmgc/guidance/lnav/legs'; import { Geometry } from '../Geometry'; import { GuidanceComponent } from '../GuidanceComponent'; +import { NavGeometryProfile, VerticalCheckpointReason, VerticalWaypointPrediction } 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; + + private takeoffPathBuilder: TakeoffPathBuilder; + + private climbPathBuilder: ClimbPathBuilder; + + private cruisePathBuilder: CruisePathBuilder; + + private tacticalDescentPathBuilder: TacticalDescentPathBuilder; - currentClimbProfile: ClimbProfileBuilderResult; + private managedDescentPathBuilder: DescentPathBuilder; - currentDescentProfile: TheoreticalDescentPathCharacteristics + private approachPathBuilder: ApproachPathBuilder; - currentApproachProfile?: DecelPathCharacteristics; + private cruiseToDescentCoordinator: CruiseToDescentCoordinator; + + currentNavGeometryProfile: NavGeometryProfile; + + currentSelectedGeometryProfile?: SelectedGeometryProfile; + + currentNdGeometryProfile?: BaseGeometryProfile; private guidanceMode: RequestedVerticalMode; @@ -34,9 +69,42 @@ export class VnavDriver implements GuidanceComponent { // eslint-disable-next-line camelcase private coarsePredictionsUpdate = new A32NX_Util.UpdateThrottler(5000); + currentMcduSpeedProfile: McduSpeedProfile; + + timeMarkers = new Map() + + 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.cruisePathBuilder = new CruisePathBuilder(computationParametersObserver, this.atmosphericConditions); + this.tacticalDescentPathBuilder = new TacticalDescentPathBuilder(this.computationParametersObserver); + this.managedDescentPathBuilder = new DescentPathBuilder(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(guidanceController); + + 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 { @@ -44,50 +112,390 @@ export class VnavDriver implements GuidanceComponent { } acceptMultipleLegGeometry(geometry: Geometry) { - this.computeVerticalProfile(geometry); + this.recompute(geometry); } - lastCruiseAltitude: Feet = 0; + private lastParameters: VerticalProfileComputationParameters = null; + + private oldGeometry: Geometry = null; update(deltaTime: number): void { - this.atmosphericConditions.update(); + try { + const { presentPosition, flightPhase } = this.computationParametersObserver.get(); + + if (this.coarsePredictionsUpdate.canUpdate(deltaTime) !== -1) { + CoarsePredictions.updatePredictions(this.guidanceController, this.atmosphericConditions); + } - if (this.coarsePredictionsUpdate.canUpdate(deltaTime) !== -1) { - CoarsePredictions.updatePredictions(this.guidanceController, this.atmosphericConditions); + if (flightPhase >= FmgcFlightPhase.Takeoff) { + this.constraintReader.updateDistanceToEnd(presentPosition); + this.updateTimeMarkers(); + this.descentGuidance.update(deltaTime, this.constraintReader.distanceToEnd); + } + } catch (e) { + console.error('[FMS] Failed to calculate vertical profile. See exception below.'); + console.error(e); } + } - const newCruiseAltitude = SimVar.GetSimVarValue('L:AIRLINER_CRUISE_ALTITUDE', 'number'); - if (newCruiseAltitude !== this.lastCruiseAltitude) { - this.lastCruiseAltitude = newCruiseAltitude; + recompute(geometry: Geometry): void { + const newParameters = this.computationParametersObserver.get(); - if (DEBUG) { - console.log('[FMS/VNAV] Computed new vertical profile because of new cruise altitude.'); + this.constraintReader.updateGeometry(geometry, newParameters.presentPosition); + this.windProfileFactory.updateAircraftDistanceFromStart(this.constraintReader.distanceToPresentPosition); + this.headingProfile.updateGeometry(geometry); + + this.computeVerticalProfileForMcdu(geometry); + + if (this.shouldUpdateDescentProfile(newParameters, geometry)) { + this.oldGeometry = geometry; + this.lastParameters = newParameters; + + if (this.currentNavGeometryProfile?.isReadyToDisplay) { + this.descentGuidance.updateProfile(this.currentNavGeometryProfile); } + } + + this.computeVerticalProfileForNd(); + this.guidanceController.pseudoWaypoints.acceptVerticalProfile(); - this.computeVerticalProfile(this.guidanceController.activeGeometry); + this.version++; + } + + 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 profile: The profile to build to the destination + * @param fromFlightPhase: The flight phase to start building the profile from + */ + 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.shouldTakeDescentSpeedLimitIntoAccount()) { + this.cruiseToDescentCoordinator.addSpeedLimitAsCheckpoint(profile); + } + } + } + + private computeVerticalProfileForMcdu(geometry: Geometry) { + const { flightPhase, presentPosition, fuelOnBoard } = this.computationParametersObserver.get(); + + this.currentNavGeometryProfile = new NavGeometryProfile(this.guidanceController, 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() { + const { fcuAltitude, fcuVerticalMode, presentPosition, fuelOnBoard, fcuVerticalSpeed, flightPhase } = this.computationParametersObserver.get(); + + this.currentNdGeometryProfile = this.isLatAutoControlActive() + ? new NavGeometryProfile(this.guidanceController, 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) && fcuAltitude < presentPosition.alt) { + // 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 vdev = this.aircraftToDescentProfileRelation.computeLinearDeviation(); + const descentStrategy = this.getAppropriateTacticalDescentStrategy(fcuVerticalMode, fcuVerticalSpeed, vdev); + const descentWinds = new HeadwindProfile(this.windProfileFactory.getDescentWinds(), this.headingProfile); + + // Build path to FCU altitude. + this.tacticalDescentPathBuilder.buildTacticalDescentPath(this.currentNdGeometryProfile, descentStrategy, speedProfile, descentWinds, fcuAltitude); + + if (this.isLatAutoControlActive() && this.aircraftToDescentProfileRelation.isValid) { + // The guidance profile is typically not refreshed after the descent is started, whereas the tactical profile is recomputed constantly, + // so the `distanceFromStart`s are very different in the profiles. + const guidanceDistanceFromStart = this.aircraftToDescentProfileRelation.distanceFromStart; + const tacticalDistanceFromStart = this.currentNdGeometryProfile.checkpoints[0].distanceFromStart; + const offset = tacticalDistanceFromStart - guidanceDistanceFromStart; + + // Compute intercept between managed profile and tactical path. + const interceptDistance = ProfileInterceptCalculator.calculateIntercept( + this.currentNdGeometryProfile.checkpoints, + this.aircraftToDescentProfileRelation.currentProfile.checkpoints, + offset, + ); + + if (interceptDistance) { + const isManagedDescent = fcuVerticalMode === VerticalMode.DES; + const reason = isManagedDescent ? VerticalCheckpointReason.InterceptDescentProfileManaged : VerticalCheckpointReason.InterceptDescentProfileSelected; + const interceptCheckpoint = this.currentNdGeometryProfile.addInterpolatedCheckpoint(interceptDistance, { reason }); + + const isTooCloseToDrawIntercept = Math.abs(vdev) < 100; + const isInterceptOnlyAtFcuAlt = interceptCheckpoint.altitude - fcuAltitude < 100; + + // In managed mode, we remove all checkpoints after (maybe including) the intercept + // In selected mode, we keep the checkpoints after the intercept + const interceptIndex = this.currentNdGeometryProfile.checkpoints.findIndex((checkpoint) => checkpoint.reason === reason); + if (isManagedDescent) { + this.currentNdGeometryProfile.checkpoints.splice( + isTooCloseToDrawIntercept || isInterceptOnlyAtFcuAlt ? interceptIndex : interceptIndex + 1, + this.currentNdGeometryProfile.checkpoints.length, + ); + } else if (isTooCloseToDrawIntercept || isInterceptOnlyAtFcuAlt) { + this.currentNdGeometryProfile.checkpoints.splice( + isTooCloseToDrawIntercept || isInterceptOnlyAtFcuAlt ? interceptIndex : interceptIndex + 1, + 1, + ); + } + } + } + } + + // After computing the tactical profile, we wish to finish it up in managed modes. + if (this.isLatAutoControlActive() && this.currentNdGeometryProfile) { + this.currentNdGeometryProfile.checkpoints.push( + ...this.currentNavGeometryProfile.checkpoints + .slice() + .filter(({ distanceFromStart }) => distanceFromStart > this.currentNdGeometryProfile.lastCheckpoint?.distanceFromStart ?? 0), + ); + + if ((tacticalDescentModes.includes(fcuVerticalMode) || fcuVerticalMode === VerticalMode.VS && fcuVerticalSpeed < 0) && fcuAltitude < presentPosition.alt) { + // It's possible that the level off arrow was spliced from the profile because we spliced it at the intercept, so we need to add it back. + const levelOffArrow = this.currentNdGeometryProfile.findVerticalCheckpoint(VerticalCheckpointReason.CrossingFcuAltitudeDescent); + let levelOffDistance = levelOffArrow?.distanceFromStart ?? 0; + + if (fcuVerticalMode === VerticalMode.DES && !levelOffArrow) { + levelOffDistance = this.currentNdGeometryProfile.interpolateDistanceAtAltitudeBackwards(fcuAltitude, true); + this.currentNdGeometryProfile.addInterpolatedCheckpoint(levelOffDistance, { reason: VerticalCheckpointReason.CrossingFcuAltitudeDescent }); + } + + for (let i = 1; i < this.currentNdGeometryProfile.checkpoints.length; i++) { + const checkpoint = this.currentNdGeometryProfile.checkpoints[i]; + if (checkpoint.distanceFromStart < levelOffDistance || checkpoint.altitude - fcuAltitude >= -10) { + continue; + } + + const previousCheckpoint = this.currentNdGeometryProfile.checkpoints[i - 1]; + + if (previousCheckpoint.reason !== VerticalCheckpointReason.PresentPosition && Math.round(previousCheckpoint.altitude) > Math.round(checkpoint.altitude)) { + this.currentNdGeometryProfile.addInterpolatedCheckpoint( + Math.max(levelOffDistance, previousCheckpoint.distanceFromStart), + { reason: VerticalCheckpointReason.ContinueDescent }, + ); + + break; + } + } + } + } + + this.currentNdGeometryProfile.finalizeProfile(); + + if (VnavConfig.DEBUG_PROFILE) { + console.log('this.currentNdGeometryProfile:', this.currentNdGeometryProfile); + } + } + + private getAppropriateTacticalDescentStrategy(fcuVerticalMode: VerticalMode, fcuVerticalSpeed: FeetPerMinute, vdev: Feet) { + if (fcuVerticalMode === VerticalMode.VS && fcuVerticalSpeed < 0) { + return new VerticalSpeedStrategy(this.computationParametersObserver, this.atmosphericConditions, fcuVerticalSpeed); + } + + if (this.aircraftToDescentProfileRelation.isValid && fcuVerticalMode === VerticalMode.DES) { + if (vdev > 0 && this.aircraftToDescentProfileRelation.isPastTopOfDescent()) { + return new IdleDescentStrategy( + this.computationParametersObserver, + this.atmosphericConditions, + { flapConfig: FlapConf.CLEAN, gearExtended: false, speedbrakesExtended: fcuVerticalMode === VerticalMode.DES }, + ); } - if (this.guidanceController.flightPlanManager.getApproach(FlightPlans.Active)) { - this.currentApproachProfile = DecelPathBuilder.computeDecelPath(geometry); - } else { - this.currentApproachProfile = null; + + 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.isLatAutoControlActive() && 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.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(); } } + isLatAutoControlActive(): boolean { + const { fcuLateralMode } = this.computationParametersObserver.get(); + + return fcuLateralMode === LateralMode.NAV + || fcuLateralMode === LateralMode.LOC_CPT + || fcuLateralMode === LateralMode.LOC_TRACK + || fcuLateralMode === LateralMode.RWY; + } + + isSelectedVerticalModeActive(): boolean { + const { fcuVerticalMode } = this.computationParametersObserver.get(); + const isExpediteModeActive = SimVar.GetSimVarValue('L:A32NX_FMA_EXPEDITE_MODE', 'number') === 1; + + return isExpediteModeActive + || fcuVerticalMode === VerticalMode.VS + || fcuVerticalMode === VerticalMode.FPA + || fcuVerticalMode === VerticalMode.OP_CLB + || fcuVerticalMode === VerticalMode.OP_DES; + } + private updateGuidance(): void { let newGuidanceMode = RequestedVerticalMode.None; let newVerticalSpeed = 0; @@ -139,4 +547,138 @@ export class VnavDriver implements GuidanceComponent { SimVar.SetSimVarValue('L:A32NX_FG_TARGET_ALTITUDE', 'number', this.targetAltitude); } } + + getLinearDeviation(): Feet | null { + if (!this.aircraftToDescentProfileRelation.isValid) { + return null; + } + + return this.aircraftToDescentProfileRelation.computeLinearDeviation(); + } + + private shouldUpdateDescentProfile(newParameters: VerticalProfileComputationParameters, geometry: Geometry): boolean { + // While in the descent phase, we don't want to update the profile anymore + if (this.lastParameters === null) { + return true; + } + + return newParameters.flightPhase < FmgcFlightPhase.Descent || newParameters.flightPhase > FmgcFlightPhase.Approach + || (!this.flightPlanManager.isCurrentFlightPlanTemporary() && this.didGeometryChange(this.oldGeometry, geometry)) + || numberOrNanChanged(this.lastParameters.cruiseAltitude, newParameters.cruiseAltitude) + || numberOrNanChanged(this.lastParameters.managedDescentSpeed, newParameters.managedDescentSpeed) + || numberOrNanChanged(this.lastParameters.managedDescentSpeedMach, newParameters.managedDescentSpeedMach) + || numberOrNanChanged(this.lastParameters.approachQnh, newParameters.approachQnh) + || numberOrNanChanged(this.lastParameters.approachTemperature, newParameters.approachTemperature); + } + + private didGeometryChange(oldGeo: Geometry, newGeo: Geometry): boolean { + if (!oldGeo || !newGeo) { + return !!oldGeo === !!newGeo; + } + + for (const [index, legA] of oldGeo.legs) { + const legB = newGeo.legs.get(index); + + if (index > this.guidanceController.activeLegIndex) { + continue; + } + + if (!legA !== !legB || !isMetadataEqual(legA.metadata, legB.metadata)) { + return true; + } + } + + return false; + } + + public getDestinationPrediction(): VerticalWaypointPrediction | null { + return this.currentNavGeometryProfile?.waypointPredictions?.get(this.flightPlanManager.getDestinationIndex()); + } + + public get distanceToEnd(): NauticalMiles { + return this.constraintReader.distanceToEnd; + } + + public findNextSpeedChange(): NauticalMiles | null { + const { presentPosition, flightPhase } = this.computationParametersObserver.get(); + + if (!Simplane.getAutoPilotAirspeedManaged() || SimVar.GetSimVarValue('L:A32NX_FMA_EXPEDITE_MODE', 'number') === 1 || flightPhase === FmgcFlightPhase.Approach) { + return null; + } + + let speedTargetType: ManagedSpeedType = ManagedSpeedType.Climb; + if (flightPhase === FmgcFlightPhase.Cruise) { + speedTargetType = ManagedSpeedType.Cruise; + } else if (flightPhase === FmgcFlightPhase.Descent) { + speedTargetType = ManagedSpeedType.Descent; + } + + const distanceToPresentPosition = this.constraintReader.distanceToPresentPosition; + + // We don't want to show the speed change dot at acceleration altiude, so we have to make sure the speed target is econ speed, not SRS speed. + const speedTarget = flightPhase < FmgcFlightPhase.Climb + ? this.currentMcduSpeedProfile.getTarget(distanceToPresentPosition, presentPosition.alt, ManagedSpeedType.Climb) + : SimVar.GetSimVarValue('L:A32NX_SPEEDS_MANAGED_PFD', 'knots'); + + for (let i = 1; i < this.currentNdGeometryProfile.checkpoints.length - 1; i++) { + const checkpoint = this.currentNdGeometryProfile.checkpoints[i]; + + if (checkpoint.distanceFromStart < distanceToPresentPosition) { + continue; + } else if (checkpoint.reason === VerticalCheckpointReason.TopOfClimb || checkpoint.reason === VerticalCheckpointReason.TopOfDescent) { + return null; + } + + if (checkpoint.speed - Math.max(this.currentNdGeometryProfile.checkpoints[i - 1].speed, speedTarget) > 1) { + // Candiate for a climb speed change + if (speedTargetType === ManagedSpeedType.Climb) { + return this.currentNdGeometryProfile.checkpoints[i - 1].distanceFromStart; + } + } else if (checkpoint.speed - Math.min(speedTarget, this.currentNdGeometryProfile.checkpoints[i - 1].speed) < -1) { + if (speedTargetType === ManagedSpeedType.Descent) { + return this.currentNdGeometryProfile.checkpoints[i - 1].distanceFromStart; + } + } + } + + return null; + } + + public invalidateFlightPlanProfile(): void { + if (this.currentNavGeometryProfile) { + this.currentNavGeometryProfile.invalidate(); + } + } +} + +/// To check whether the value changed from old to new, but not if both values are NaN. (NaN !== NaN in JS) +export function numberOrNanChanged(oldValue: number, newValue: number): boolean { + return Number.isNaN(oldValue) !== Number.isNaN(newValue) && oldValue !== newValue; +} + +function isMetadataEqual(a: LegMetadata, b: LegMetadata): boolean { + return areAltitudeConstraintsEqual(a.altitudeConstraint, b.altitudeConstraint) + && a.pathAngleConstraint === b.pathAngleConstraint + && a.isOverfly === b.isOverfly + && !numberOrNanChanged(a.offset, b.offset) + && a.turnDirection === b.turnDirection + && !numberOrNanChanged(a.rtaUtcSeconds, b.rtaUtcSeconds) + && areSpeedConstraintsEqual(a.speedConstraint, b.speedConstraint) + && a.turnDirection === b.turnDirection; +} + +function areAltitudeConstraintsEqual(a: AltitudeConstraint, b: AltitudeConstraint): boolean { + if (!a || !b) { + return !a === !b; + } + + return a.type === b.type && !numberOrNanChanged(Math.round(a.altitude1), Math.round(b.altitude1)) && !numberOrNanChanged(Math.round(a.altitude2), Math.round(b.altitude2)); +} + +function areSpeedConstraintsEqual(a: SpeedConstraint, b: SpeedConstraint): boolean { + if (!a || !b) { + return !a === !b; + } + + return a.type === b.type && !numberOrNanChanged(Math.round(a.speed), Math.round(b.speed)); } diff --git a/src/fmgc/src/guidance/vnav/climb/ClimbPathBuilder.ts b/src/fmgc/src/guidance/vnav/climb/ClimbPathBuilder.ts index f98081e8419..652b5f68045 100644 --- a/src/fmgc/src/guidance/vnav/climb/ClimbPathBuilder.ts +++ b/src/fmgc/src/guidance/vnav/climb/ClimbPathBuilder.ts @@ -1,85 +1,376 @@ -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 { 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.shouldTakeClimbSpeedLimitIntoAccount()) { + 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(); + + // This is just to prevent a potential infinite loop + let i = 0; + for (let altitude = startingAltitude; i++ < 100 && altitude < targetAltitude;) { + const { speed, remainingFuelOnBoard, distanceFromStart } = profile.lastCheckpoint; + + const speedTarget = speedProfile.getTarget(distanceFromStart, altitude, ManagedSpeedType.Climb); + const isAboveCrossoverAltitude = speedTarget > this.atmosphericConditions.computeCasFromMach(altitude, managedClimbSpeedMach); + + const headwind = windProfile.getHeadwindComponent(distanceFromStart, altitude); + + // If we're below the target speed, we need to accelerate, unless we're above the crossover altitude. In that case, IAS is always below the managed IAS speed. + const step = isAboveCrossoverAltitude || 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)}`); + } - console.log(`[FMS/VNAV] T/C: ${JSON.stringify(takeoffRollDistance + distanceTraveledSrs + distanceTraveledClb)}`); + addSpeedLimitAsCheckpoint(profile: BaseGeometryProfile) { + const { climbSpeedLimit: { underAltitude }, presentPosition: { alt }, cruiseAltitude } = this.computationParametersObserver.get(); - return { distanceToAccelerationAltitude: distanceTraveledSrs }; + if (underAltitude <= alt || underAltitude > cruiseAltitude) { + return; + } + + 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) { + 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.finalAltitude; + 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 60d12722720..e69de29bb2d 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 00000000000..3a997c10158 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/climb/ClimbStrategy.ts @@ -0,0 +1,227 @@ +import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters'; +import { DescentStrategy } from '@fmgc/guidance/vnav/descent/DescentStrategy'; +import { WindComponent } from '@fmgc/guidance/vnav/wind'; +import { VnavConfig } from '@fmgc/guidance/vnav/VnavConfig'; +import { AircraftConfiguration } from '@fmgc/guidance/vnav/descent/ApproachPathBuilder'; +import { MathUtils } from '@shared/MathUtils'; +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(); + + const computedMach = Math.min(this.atmosphericConditions.computeMachFromCas(initialAltitude, speed), mach); + + const n1 = this.verticalSpeed > 0 + ? getClimbThrustN1Limit(this.atmosphericConditions, initialAltitude, speed, managedClimbSpeedMach) + : EngineModel.getIdleN1(initialAltitude, computedMach) + VnavConfig.IDLE_N1_MARGIN; + + return Predictions.verticalSpeedStepWithSpeedChange( + initialAltitude, + speed, + finalSpeed, + this.verticalSpeed, + mach, + n1, + zeroFuelWeight, + fuelOnBoard, + headwindComponent.value, + this.atmosphericConditions.isaDeviation, + tropoPause, + false, + FlapConf.CLEAN, + perfFactor, + ); + } +} + +export class FlightPathAngleStrategy implements ClimbStrategy, DescentStrategy { + constructor(private observer: VerticalProfileComputationParametersObserver, private atmosphericConditions: AtmosphericConditions, public flightPathAngle: Radians) { } + + predictToAltitude(_initialAltitude: Feet, _finalAltitude: Feet, _speed: Knots, _mach: Mach, _fuelOnBoard: number, _headwindComponent: WindComponent): StepResults { + throw new Error('[FMS/VNAV] Predictions to altitude not implemented for FPA strategy'); + } + + predictToDistance( + initialAltitude: Feet, distance: NauticalMiles, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent, config?: AircraftConfiguration, + ): StepResults { + const { zeroFuelWeight, perfFactor } = this.observer.get(); + + const finalAltitde = initialAltitude + 6076.12 * distance * Math.tan(this.flightPathAngle * MathUtils.DEGREES_TO_RADIANS); + + return Predictions.geometricStep( + initialAltitude, + finalAltitde, + distance, + speed, + mach, + zeroFuelWeight, + fuelOnBoard, + this.atmosphericConditions.isaDeviation, + headwindComponent.value, + perfFactor, + config?.gearExtended ?? false, + config?.flapConfig ?? FlapConf.CLEAN, + config?.speedbrakesExtended ?? false, + ); + } + + /** + * If the path is being built backwards and we are trying to calculate a deceleration segment, `finalSpeed` should be greater than `speed`. + * In this case, this predicts a segment where the aircraft decelerates to `finalSpeed` from `speed`. + */ + predictToSpeed(initialAltitude: Feet, finalSpeed: Knots, speed: Knots, mach: Mach, fuelOnBoard: number, headwindComponent: WindComponent, config?: AircraftConfiguration): StepResults { + const { zeroFuelWeight, perfFactor, tropoPause, managedClimbSpeedMach } = this.observer.get(); + + const computedMach = Math.min(this.atmosphericConditions.computeMachFromCas(initialAltitude, speed), mach); + const predictedN1 = this.flightPathAngle > 0 + ? getClimbThrustN1Limit(this.atmosphericConditions, initialAltitude, speed, managedClimbSpeedMach) + : EngineModel.getIdleN1(initialAltitude, computedMach) + VnavConfig.IDLE_N1_MARGIN; + + return Predictions.speedChangeStep( + this.flightPathAngle, + initialAltitude, + speed, + finalSpeed, + mach, + mach, + predictedN1, + zeroFuelWeight, + fuelOnBoard, + headwindComponent.value, + this.atmosphericConditions.isaDeviation, + tropoPause, + config?.gearExtended ?? false, + config?.flapConfig ?? FlapConf.CLEAN, + config?.speedbrakesExtended ?? false, + perfFactor, + ); + } +} + +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 00000000000..ec665939305 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/climb/SpeedProfile.ts @@ -0,0 +1,454 @@ +import { MaxSpeedConstraint } from '@fmgc/guidance/vnav/profile/NavGeometryProfile'; +import { SpeedLimit } from '@fmgc/guidance/vnav/SpeedLimit'; +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; + shouldTakeClimbSpeedLimitIntoAccount(): boolean; + shouldTakeDescentSpeedLimitIntoAccount(): 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(speedLimit: SpeedLimit): boolean { + if (!speedLimit) { + return false; + } + + const { speed, underAltitude } = speedLimit; + + 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 < FmgcFlightPhase.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 { climbSpeedLimit, descentSpeedLimit } = this.parameters.get(); + + if (managedSpeedType === ManagedSpeedType.Climb || managedSpeedType === ManagedSpeedType.Cruise) { + if (this.shouldTakeClimbSpeedLimitIntoAccount() && altitude < climbSpeedLimit.underAltitude) { + managedSpeed = Math.min(climbSpeedLimit.speed, managedSpeed); + } + } else if (this.shouldTakeDescentSpeedLimitIntoAccount() && altitude < descentSpeedLimit.underAltitude) { + managedSpeed = Math.min(descentSpeedLimit.speed, managedSpeed); + } + + return managedSpeed; + } + + private getManagedTarget(distanceFromStart: NauticalMiles, altitude: Feet, managedSpeedType: ManagedSpeedType): Knots { + let managedSpeed = this.getManagedSpeedForType(managedSpeedType); + const { climbSpeedLimit, descentSpeedLimit } = this.parameters.get(); + + if (managedSpeedType === ManagedSpeedType.Climb || managedSpeedType === ManagedSpeedType.Cruise) { + if (this.shouldTakeClimbSpeedLimitIntoAccount() && altitude < climbSpeedLimit.underAltitude) { + managedSpeed = Math.min(climbSpeedLimit.speed, managedSpeed); + } + } else if (this.shouldTakeDescentSpeedLimitIntoAccount() && altitude < descentSpeedLimit.underAltitude) { + managedSpeed = Math.min(descentSpeedLimit.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`, + ); + } + + shouldTakeClimbSpeedLimitIntoAccount(): boolean { + return this.isValidSpeedLimit(this.parameters.get().climbSpeedLimit); + } + + shouldTakeDescentSpeedLimitIntoAccount(): boolean { + return this.isValidSpeedLimit(this.parameters.get().descentSpeedLimit); + } + + 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; + } + + shouldTakeClimbSpeedLimitIntoAccount(): boolean { + return false; + } + + shouldTakeDescentSpeedLimitIntoAccount(): 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(speedLimit: SpeedLimit): boolean { + if (!speedLimit) { + return false; + } + + const { speed, underAltitude } = speedLimit; + + 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 { climbSpeedLimit, descentSpeedLimit } = this.parameters.get(); + + if (managedSpeedType === ManagedSpeedType.Climb || managedSpeedType === ManagedSpeedType.Cruise) { + if (this.shouldTakeClimbSpeedLimitIntoAccount() && altitude < climbSpeedLimit.underAltitude) { + managedSpeed = Math.min(climbSpeedLimit.speed, managedSpeed); + } + } else if (this.shouldTakeDescentSpeedLimitIntoAccount() && altitude < descentSpeedLimit.underAltitude) { + managedSpeed = Math.min(descentSpeedLimit.speed, managedSpeed); + } + + return managedSpeed; + } + + private getManaged(distanceFromStart: NauticalMiles, altitude: Feet, managedSpeedType: ManagedSpeedType): Knots { + let managedSpeed = this.getManagedSpeedForType(managedSpeedType); + const { climbSpeedLimit, descentSpeedLimit } = this.parameters.get(); + + if (managedSpeedType === ManagedSpeedType.Climb || managedSpeedType === ManagedSpeedType.Cruise) { + if (this.shouldTakeClimbSpeedLimitIntoAccount() && altitude < climbSpeedLimit.underAltitude) { + managedSpeed = Math.min(climbSpeedLimit.speed, managedSpeed); + } + } else if (this.shouldTakeDescentSpeedLimitIntoAccount() && altitude < descentSpeedLimit.underAltitude) { + managedSpeed = Math.min(descentSpeedLimit.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`, + ); + } + + shouldTakeClimbSpeedLimitIntoAccount(): boolean { + return this.isValidSpeedLimit(this.parameters.get().climbSpeedLimit) && !this.isSelectedSpeed(); + } + + shouldTakeDescentSpeedLimitIntoAccount(): boolean { + return this.isValidSpeedLimit(this.parameters.get().descentSpeedLimit) && !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 44c24b6e187..c29e4ffac1c 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 00000000000..b899ccf92a7 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/cruise/CruisePathBuilder.ts @@ -0,0 +1,200 @@ +import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters'; +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 { GeographicCruiseStep, MaxSpeedConstraint, VerticalCheckpoint, VerticalCheckpointReason } from '../profile/NavGeometryProfile'; +import { AtmosphericConditions } from '../AtmosphericConditions'; + +export class CruisePathBuilder { + constructor(private computationParametersObserver: VerticalProfileComputationParametersObserver, + private atmosphericConditions: AtmosphericConditions) { } + + 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 profile.cruiseSteps) { + // If the step is too close to T/D + 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(altitude, 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); + } + + if (sequence.lastCheckpoint.distanceFromStart >= targetDistanceFromStart) { + return sequence; + } + + 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( + sequence.lastCheckpoint.altitude, + 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( + altitude, + speedConstraint.distanceFromStart - distanceFromStart, + remainingFuelOnBoard, + speed, + windProfile.getHeadwindComponent(distanceFromStart, altitude), + ); + + sequence.addCheckpointFromStep(segmentResult, VerticalCheckpointReason.SpeedConstraint); + } + + private addStepFromLastCheckpoint(sequence: TemporaryCheckpointSequence, step: GeographicCruiseStep, 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(altitude: Feet, distance: NauticalMiles, remainingFuelOnBoard: number, speed: Knots, headwind: WindComponent): StepResults { + const { zeroFuelWeight, managedCruiseSpeedMach } = this.computationParametersObserver.get(); + + return Predictions.levelFlightStep( + altitude, + 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(profile: BaseGeometryProfile): Feet { + const { cruiseAltitude } = this.computationParametersObserver.get(); + + if (profile.cruiseSteps.length === 0) { + return cruiseAltitude; + } + + return profile.cruiseSteps[profile.cruiseSteps.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 00000000000..ac2dcc4b565 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/descent/AircraftToProfileRelation.ts @@ -0,0 +1,128 @@ +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; + + public currentProfile?: NavGeometryProfile; + + private topOfDescent?: VerticalCheckpoint; + + private geometricPathStart?: VerticalCheckpoint; + + private distanceToEnd: NauticalMiles = 0;; + + public totalFlightPlanDistance: number = 0; + + get distanceFromStart(): NauticalMiles { + return this.totalFlightPlanDistance - this.distanceToEnd; + } + + constructor(private observer: VerticalProfileComputationParametersObserver) { } + + 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.totalFlightPlanDistance = profile.totalFlightPlanDistance; + + this.distanceToEnd = profile.totalFlightPlanDistance - profile.distanceToPresentPosition; + } + + private invalidate() { + this.isValid = false; + this.currentProfile = undefined; + this.topOfDescent = undefined; + } + + update(distanceToEnd: number) { + if (!this.isValid) { + return; + } + + this.distanceToEnd = distanceToEnd; + } + + isPastTopOfDescent(): boolean { + return this.distanceToTopOfDescent() < 0; + } + + distanceToTopOfDescent(): number | null { + if (this.topOfDescent) { + return this.topOfDescent.distanceFromStart - this.distanceFromStart; + } + + return null; + } + + isOnGeometricPath(): boolean { + return this.distanceFromStart > 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.distanceFromStart); + } + + currentTargetSpeed(): Feet { + return this.currentProfile.findNextSpeedTarget(this.distanceFromStart); + } + + currentTargetPathAngle(): Degrees { + return this.currentProfile.interpolatePathAngleAtDistance(this.distanceFromStart); + } + + 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; + } + + isCloseToAirfieldElevation(): boolean { + const { destinationAirfieldElevation, presentPosition } = this.observer.get(); + + return presentPosition.alt < destinationAirfieldElevation + 5000; + } + + get currentDistanceToEnd(): NauticalMiles { + return this.distanceToEnd; + } +} 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 00000000000..fcd9b352c9e --- /dev/null +++ b/src/fmgc/src/guidance/vnav/descent/ApproachPathBuilder.ts @@ -0,0 +1,412 @@ +// Copyright (c) 2021 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { 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 { VerticalProfileComputationParameters, 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 { TemporaryCheckpointSequence } from '@fmgc/guidance/vnav/profile/TemporaryCheckpointSequence'; +import { HeadwindProfile } from '@fmgc/guidance/vnav/wind/HeadwindProfile'; +import { VnavConfig } from '@fmgc/guidance/vnav/VnavConfig'; +import { FlightPathAngleStrategy } from '@fmgc/guidance/vnav/climb/ClimbStrategy'; + +class FlapConfigurationProfile { + static getBySpeed(speed: Knots, parameters: VerticalProfileComputationParameters): FlapConf { + if (speed > parameters.cleanSpeed) { + return FlapConf.CLEAN; + } if (speed > parameters.slatRetractionSpeed) { + return FlapConf.CONF_1; // Between S and O + } if (speed > parameters.flapRetractionSpeed) { + return FlapConf.CONF_2; // Between F and S + } if (speed > (parameters.flapRetractionSpeed + parameters.approachSpeed) / 2) { + return FlapConf.CONF_3; + } + + return FlapConf.CONF_FULL; + } + + static findNextExtensionSpeed(speed: Knots, parameters: VerticalProfileComputationParameters) { + if (speed < (parameters.flapRetractionSpeed + parameters.approachSpeed) / 2) { + return (parameters.flapRetractionSpeed + parameters.approachSpeed) / 2; + } if (speed < parameters.flapRetractionSpeed) { + return parameters.flapRetractionSpeed; + } if (speed < parameters.slatRetractionSpeed) { + return parameters.slatRetractionSpeed; + } if (speed < parameters.cleanSpeed) { + return parameters.cleanSpeed; + } + + return Infinity; + } +} + +export interface AircraftConfiguration { + flapConfig: FlapConf + speedbrakesExtended: boolean + gearExtended: boolean +} + +export class AircraftConfigurationProfile { + static getBySpeed(speed: Knots, parameters: VerticalProfileComputationParameters): AircraftConfiguration { + return { + flapConfig: FlapConfigurationProfile.getBySpeed(speed, parameters), + speedbrakesExtended: false, + gearExtended: speed < parameters.flapRetractionSpeed, + }; + } +} + +export class ApproachPathBuilder { + private idleStrategy: DescentStrategy; + + private fpaStrategy: FlightPathAngleStrategy; + + constructor(private observer: VerticalProfileComputationParametersObserver, private atmosphericConditions: AtmosphericConditions) { + this.idleStrategy = new IdleDescentStrategy(observer, atmosphericConditions); + this.fpaStrategy = new FlightPathAngleStrategy(observer, atmosphericConditions, 0); + } + + computeApproachPath( + profile: NavGeometryProfile, speedProfile: SpeedProfile, windProfile: HeadwindProfile, estimatedFuelOnBoardAtDestination: number, estimatedSecondsFromPresentAtDestination: number, + ): TemporaryCheckpointSequence { + const { approachSpeed, managedDescentSpeedMach, destinationAirfieldElevation, cleanSpeed } = 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, + }); + + const distanceToOneThousandAgl = 1000 / Math.tan(-profile.finalDescentAngle * MathUtils.DEGREES_TO_RADIANS) / 6076.12; + + // Build final segment + this.fpaStrategy.flightPathAngle = -profile.finalDescentAngle; + const finalApproachStep = this.fpaStrategy.predictToDistance( + finalAltitude, + distanceToOneThousandAgl, + approachSpeed, + managedDescentSpeedMach, + estimatedFuelOnBoardAtDestination, + windProfile.getHeadwindComponent(profile.getDistanceFromStart(0), finalAltitude), + AircraftConfigurationProfile.getBySpeed(approachSpeed, this.observer.get()), + ); + + sequence.addCheckpointFromStep(finalApproachStep, VerticalCheckpointReason.AtmosphericConditions); + + // Build path to FAF by flying the descent angle but decelerating + const fafStep = this.buildDecelerationPath(sequence, speedProfile, windProfile, profile.getDistanceFromStart(profile.fafDistanceToEnd)); + sequence.push(...fafStep.get()); + + // + this.fpaStrategy.flightPathAngle = 0; + for (const altitudeConstraint of approachConstraints) { + this.handleAltitudeConstraint(sequence, speedProfile, windProfile, altitudeConstraint); + + // If you're at or above your descent speed (taking speed limit into account, place the decel point) + const speedTarget = speedProfile.getTarget(sequence.lastCheckpoint.distanceFromStart, sequence.lastCheckpoint.altitude, ManagedSpeedType.Descent); + if (sequence.lastCheckpoint.reason === VerticalCheckpointReason.Decel || sequence.lastCheckpoint.speed > cleanSpeed && sequence.lastCheckpoint.speed > speedTarget) { + break; + } + } + + const speedTarget = speedProfile.getTarget(sequence.lastCheckpoint.distanceFromStart, sequence.lastCheckpoint.altitude, ManagedSpeedType.Descent); + + if (speedTarget - sequence.lastCheckpoint.speed > 0.1) { + // We use -Infinty because we just want to decelerate to the descent speed without and constraint on distance + const decelerationToDescentSpeed = this.buildDecelerationPath(sequence, speedProfile, windProfile, -Infinity); + 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, windProfile: HeadwindProfile, 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, cleanSpeed } = 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; + + // This should be positive + const desiredDistanceToCover = distanceFromStart - constraint.distanceFromStart; + + let decelerationSequence: TemporaryCheckpointSequence = null; + let descentSegment: StepResults = null; + + // `decelerationSegmentDistance` should be positive + const tryDecelDistance = (decelerationSegmentDistance: NauticalMiles): NauticalMiles => { + decelerationSequence = this.buildDecelerationPath( + sequence, + speedProfile, + windProfile, + distanceFromStart - decelerationSegmentDistance, + ); + + descentSegment = this.idleStrategy.predictToAltitude( + altitude, + minimumAltitude, + decelerationSequence.lastCheckpoint.speed, + managedDescentSpeedMach, + decelerationSequence.lastCheckpoint.remainingFuelOnBoard, + windProfile.getHeadwindComponent(distanceFromStart - decelerationSegmentDistance, minimumAltitude), + AircraftConfigurationProfile.getBySpeed(decelerationSequence.lastCheckpoint.speed, this.observer.get()), + ); + + if (descentSegment.distanceTraveled > 0) { + throw new Error('[FMS/VNAV] Descent segment should not have a negative distance traveled'); + } + + // This should be positive + const distanceTraveled = -descentSegment.distanceTraveled + (distanceFromStart - decelerationSequence.lastCheckpoint.distanceFromStart); + + return distanceTraveled - desiredDistanceToCover; + }; + + let a = 0; + let b = desiredDistanceToCover; + + let fa = tryDecelDistance(0); + // We can't reach the altitude constraint even if we do not decelerate at all + if (fa < 0) { + let i = 0; + while (i++ < 10) { + const c = (a + b) / 2; + const fc = tryDecelDistance(c); + + if (fc < 0.05 && decelerationSequence.lastCheckpoint.reason === VerticalCheckpointReason.Decel) { + if (VnavConfig.DEBUG_PROFILE) { + console.log('[FMS/VNAV] Stopping iteration because DECEL point was found.'); + } + + break; + } + + if (Math.abs(fc) < 0.05 || (b - a) < 0.05) { + if (VnavConfig.DEBUG_PROFILE) { + console.log(`[FMS/VNAV] Final error ${fc} after ${i} iterations.`); + } + + break; + } + + if (fa * fc > 0) { + a = c; + } else { + b = c; + } + + fa = tryDecelDistance(a); + } + + if (i > 10) { + console.log(`[FMS/VNAV] Iteration did not terminate when going from ${altitude} ft to ${minimumAltitude} ft in ${desiredDistanceToCover} NM.`); + } + } + + sequence.push(...decelerationSequence.get()); + + const speedTarget = speedProfile.getTarget(decelerationSequence.lastCheckpoint.distanceFromStart, decelerationSequence.lastCheckpoint.altitude, ManagedSpeedType.Descent); + // Don't bother considering the climb step in the profile if we have already reached the target speed in the deceleration segment + if (speedTarget - decelerationSequence.lastCheckpoint.speed > 1 || decelerationSequence.lastCheckpoint.speed < cleanSpeed) { + sequence.addCheckpointFromStep(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.finalAltitude; + step.speed = (1 - scaling) * lastCheckpoint.speed + scaling * step.speed; + } + + /** + * This builds a level deceleration path, bringing out flaps as needed, and obeying speed constraints. + * This relies on `this.fpaStrategy` to have the correct descent angle coded. + * @param sequence + * @param speedProfile + * @param windProfile + * @param targetDistanceFromStart + * @returns + */ + private buildDecelerationPath( + sequence: TemporaryCheckpointSequence, speedProfile: SpeedProfile, windProfile: HeadwindProfile, targetDistanceFromStart: NauticalMiles, + ): TemporaryCheckpointSequence { + const decelerationSequence = new TemporaryCheckpointSequence(sequence.lastCheckpoint); + + const parameters = this.observer.get(); + const { managedDescentSpeedMach } = parameters; + + for (let i = 0; i < 10 + && decelerationSequence.lastCheckpoint.reason !== VerticalCheckpointReason.Decel + && decelerationSequence.lastCheckpoint.distanceFromStart - targetDistanceFromStart > 0.05; + i++ + ) { + const { distanceFromStart, altitude, speed, remainingFuelOnBoard } = decelerationSequence.lastCheckpoint; + + const speedConstraint = speedProfile.getMaxDescentSpeedConstraint(distanceFromStart - 1e-4); + const flapTargetSpeed = FlapConfigurationProfile.findNextExtensionSpeed(speed, parameters); + + // 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) { + // This is meant to be negative + const remainingDistance = Math.max(speedConstraint.distanceFromStart, targetDistanceFromStart) - distanceFromStart; + + // Decelerate to constraint + const decelerationStep = this.fpaStrategy.predictToSpeed( + altitude, + speedConstraint.maxSpeed, + speed, + managedDescentSpeedMach, + remainingFuelOnBoard, + windProfile.getHeadwindComponent(distanceFromStart, altitude), + AircraftConfigurationProfile.getBySpeed(speed, parameters), + ); + + if (decelerationStep.distanceTraveled > 0) { + throw new Error('[FMS/VNAV] Deceleration step distance should not be positive'); + } + + 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.addCheckpointFromStep(decelerationStep, VerticalCheckpointReason.AtmosphericConditions); + } + + const remainingDistanceToConstraint = distanceFromStart + decelerationStep.distanceTraveled - Math.max(speedConstraint.distanceFromStart, targetDistanceFromStart); + + if (remainingDistanceToConstraint > 0.05) { + if (speedConstraint.maxSpeed > parameters.cleanSpeed) { + decelerationSequence.lastCheckpoint.reason = VerticalCheckpointReason.Decel; + return decelerationSequence; + } + + // If we decelerated, but aren't at the constraint yet, fly level, at constant speed to the constraint + const constantStep = this.fpaStrategy.predictToDistance( + altitude, + -remainingDistanceToConstraint, + speedConstraint.maxSpeed, + managedDescentSpeedMach, + remainingFuelOnBoard - decelerationStep.fuelBurned, + windProfile.getHeadwindComponent(distanceFromStart, altitude), + AircraftConfigurationProfile.getBySpeed(speedConstraint.maxSpeed, parameters), + ); + + if (constantStep.distanceTraveled > 0) { + throw new Error('[FMS/VNAV] Descent step distance should not be positive'); + } + + decelerationSequence.addCheckpointFromStep(constantStep, VerticalCheckpointReason.SpeedConstraint); + } else { + decelerationSequence.copyLastCheckpoint({ reason: VerticalCheckpointReason.SpeedConstraint }); + } + } else { + const remainingDistance = targetDistanceFromStart - distanceFromStart; // This should be negative + const speedTargetWithConstraints = speedProfile.getTarget(distanceFromStart, decelerationSequence.lastCheckpoint.altitude, ManagedSpeedType.Descent); + + const targetSpeed = Math.min(flapTargetSpeed, speedTargetWithConstraints); + // flapTarget is constraining + const decelerationStep = this.fpaStrategy.predictToSpeed( + altitude, + targetSpeed, + speed, + managedDescentSpeedMach, + remainingFuelOnBoard, + windProfile.getHeadwindComponent(distanceFromStart, altitude), + AircraftConfigurationProfile.getBySpeed(speed, parameters), + ); + + if (decelerationStep.distanceTraveled < remainingDistance) { + const scaling = Math.min(1, remainingDistance / decelerationStep.distanceTraveled); + this.scaleStepBasedOnLastCheckpoint(decelerationSequence.lastCheckpoint, decelerationStep, scaling); + decelerationSequence.addCheckpointFromStep(decelerationStep, VerticalCheckpointReason.AtmosphericConditions); + + return decelerationSequence; + } + + decelerationSequence.addCheckpointFromStep(decelerationStep, this.getFlapCheckpointReasonByFlapConf(FlapConfigurationProfile.getBySpeed(targetSpeed, parameters))); + } + } + + return decelerationSequence; + } + + 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 deleted file mode 100644 index 74002d0bdc6..00000000000 --- a/src/fmgc/src/guidance/vnav/descent/DecelPathBuilder.ts +++ /dev/null @@ -1,328 +0,0 @@ -// Copyright (c) 2021 FlyByWire Simulations -// SPDX-License-Identifier: GPL-3.0 - -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'; - -const ALTITUDE_ADJUSTMENT_FACTOR = 1.4; - -/** - * The minimum deceleration rate, in knots per second, to target on the approach path. - * - * This will be used as the target rate in case it cannot be achieved using the desired fpa. - */ -const MINIMUM_APPROACH_DECELERATION = 0.5; - -export enum ApproachPathSegmentType { - CONSTANT_SLOPE, - CONSTANT_SPEED, - LEVEL_DECELERATION, -} - -export interface DecelPathCharacteristics { - flap1: NauticalMiles, - flap2: NauticalMiles, - decel: NauticalMiles, - top: Feet, -} - -export class DecelPathBuilder { - static computeDecelPath( - geometry: Geometry, - ): DecelPathCharacteristics { - // 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 - // If no other alt constraints, use 3 degree descent from cruise altitude - - // Given FPA above, calculate distance required (backwards from Vapp @ runway threshold alt + 50ft + 1000ft), - // to decelerate from green dot speed to Vapp using `decelerationFromGeometricStep` - // Then, add a speedChangeStep (1.33 knots/second decel) backwards from this point (green dot spd) to previous speed, aka min(last spd constraint, spd lim) - // - 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); - - let fuelWeight = TEMP_FUEL_WEIGHT; - - const cFullTo3Segment = DecelPathBuilder.computeConfigurationChangeSegment( - ApproachPathSegmentType.CONSTANT_SLOPE, - -3, - 1_000, - F, - 135, - fuelWeight, - FlapConf.CONF_FULL, - true, - TEMP_TROPO, - ); - fuelWeight += cFullTo3Segment.fuelBurned; - - const c3to2Segment = DecelPathBuilder.computeConfigurationChangeSegment( - ApproachPathSegmentType.CONSTANT_SLOPE, - -3, - cFullTo3Segment.initialAltitude, - F + (S - F) / 2, - F, - fuelWeight, - FlapConf.CONF_3, - true, - TEMP_TROPO, - ); - fuelWeight += c3to2Segment.fuelBurned; - - const c2to1Segment = DecelPathBuilder.computeConfigurationChangeSegment( - ApproachPathSegmentType.CONSTANT_SLOPE, - -3, - c3to2Segment.initialAltitude, - S, - F + (S - F) / 2, - fuelWeight, - FlapConf.CONF_2, - false, - TEMP_TROPO, - ); - fuelWeight += c2to1Segment.fuelBurned; - - const c1toCleanSegment = DecelPathBuilder.computeConfigurationChangeSegment( - ApproachPathSegmentType.CONSTANT_SLOPE, - -2.5, - c2to1Segment.initialAltitude, - O, - S, - fuelWeight, - FlapConf.CONF_1, - false, - TEMP_TROPO, - ); - fuelWeight += c1toCleanSegment.fuelBurned; - - let cleanToDesSpeedSegment = DecelPathBuilder.computeConfigurationChangeSegment( - ApproachPathSegmentType.CONSTANT_SLOPE, - -2.5, - c1toCleanSegment.initialAltitude, - DES, - O, - fuelWeight, - FlapConf.CLEAN, - false, - TEMP_TROPO, - ); - - // TODO for TOO_LOW_DECELERATION do CONSTANT_DECELERATION, not LEVEL_DECELERATION - if (cleanToDesSpeedSegment.error === VnavStepError.AVAILABLE_GRADIENT_INSUFFICIENT - || cleanToDesSpeedSegment.error === VnavStepError.TOO_LOW_DECELERATION) { - if (DEBUG) { - console.warn('[VNAV/computeDecelPath] AVAILABLE_GRADIENT_INSUFFICIENT/TOO_LOW_DECELERATION on cleanToDesSpeedSegment -> reverting to LEVEL_DECELERATION segment.'); - } - - // if (VnavConfig.VNAV_DESCENT_MODE !== VnavDescentMode.CDA) { - cleanToDesSpeedSegment = DecelPathBuilder.computeConfigurationChangeSegment( - ApproachPathSegmentType.LEVEL_DECELERATION, - undefined, - c1toCleanSegment.initialAltitude, - DES, - O, - fuelWeight, - FlapConf.CLEAN, - false, - TEMP_TROPO, - ); - // } 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 - }; - } - - /** - * Calculates a config change segment of the DECEL path. - * - * @return the config change segment step results - */ - private static computeConfigurationChangeSegment( - type: ApproachPathSegmentType, - fpa: number, - finalAltitude: Feet, - fromSpeed: Knots, - toSpeed: Knots, - initialFuelWeight: number, // TODO take finalFuelWeight and make an iterative prediction - newConfiguration: FlapConf, - gearExtended: boolean, - tropoAltitude: number, - ): StepResults { - // 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. - - switch (type) { - case ApproachPathSegmentType.CONSTANT_SLOPE: // FIXME hard-coded to -3deg in speedChangeStep - - let currentIterationAltitude = finalAltitude * ALTITUDE_ADJUSTMENT_FACTOR; - let stepResults: StepResults; - let altitudeError = 0; - let iterationCount = 0; - - if (DEBUG) { - console.log('starting iterative step compute'); - console.time(`step to altitude ${finalAltitude}`); - } - - do { - if (DEBUG) { - console.log(`iteration #${iterationCount}, with initialAltitude = ${currentIterationAltitude}, targetFinalAltitude = ${finalAltitude}`); - - console.time(`step to altitude ${finalAltitude} iteration ${iterationCount}`); - } - - const newStepResults = Predictions.speedChangeStep( - fpa ?? -3, - currentIterationAltitude, - fromSpeed, - toSpeed, - 999, - 999, - 26, - 107_000, - initialFuelWeight, - 2, - 0, - tropoAltitude, - gearExtended, - newConfiguration, - MINIMUM_APPROACH_DECELERATION, - ); - - // Stop if we encounter a NaN - if (Number.isNaN(newStepResults.finalAltitude)) { - if (DEBUG) { - console.timeEnd(`step to altitude ${finalAltitude} iteration ${iterationCount}`); - } - break; - } - - stepResults = newStepResults; - - altitudeError = finalAltitude - stepResults.finalAltitude; - currentIterationAltitude += altitudeError; - - if (DEBUG) { - console.timeEnd('stuff after'); - - console.log(`iteration #${iterationCount} done finalAltitude = ${stepResults.finalAltitude}, error = ${altitudeError}`); - - console.timeEnd(`step to altitude ${finalAltitude} iteration ${iterationCount}`); - } - - iterationCount++; - } while (Math.abs(altitudeError) >= 25 && iterationCount < 4); - - if (DEBUG) { - console.timeEnd(`step to altitude ${finalAltitude}`); - console.log('done with iterative step compute'); - } - - return { - ...stepResults, - initialAltitude: currentIterationAltitude, - }; - 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, - ); - default: - throw new Error('[FMS/VNAV/computeConfigurationChangeSegment] Unknown segment type.'); - } - } - - /** - * Returns altitude of either, in order of priority: - * - runway threshold; - * - missed approach point; - * - airport. - */ - private static findLastApproachPoint( - geometry: Geometry, - ): Feet { - const lastLeg = geometry.legs.get(geometry.legs.size - 1); - - // Last leg is TF AND is runway or airport - if (lastLeg instanceof TFLeg && (lastLeg.to.isRunway || lastLeg.to.type === 'A')) { - return lastLeg.to.legAltitude1; - } - return 150; // TODO temporary value - } -} 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 4eb91128359..00000000000 --- 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 ?? 0; - const fpa = 3; - - if (DEBUG) { - console.log(cruiseAlt); - console.log(verticalDistance); - } - - const tod = decelPath?.decel ?? 0 + (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 00000000000..bfe1f6ebd67 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/descent/DescentGuidance.ts @@ -0,0 +1,290 @@ +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'; +import { TodGuidance } from './TodGuidance'; + +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 todGuidance: TodGuidance; + + private speedTarget: Knots | Mach; + + // An "overspeed condition" just means we are above the speed margins, not that we are in the red band. + // We use a boolean here for hysteresis + 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.todGuidance = new TodGuidance(this.aircraftToDescentProfileRelation, this.observer, this.atmosphericConditions); + + 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(deltaTime: number, distanceToEnd: NauticalMiles) { + this.aircraftToDescentProfileRelation.update(distanceToEnd); + + 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.todGuidance.update(deltaTime); + } + + private updateLinearDeviation() { + const { fcuVerticalMode, flightPhase } = this.observer.get(); + + this.targetAltitude = this.aircraftToDescentProfileRelation.currentTargetAltitude(); + + this.showLinearDeviationOnPfd = flightPhase < FmgcFlightPhase.GoAround && (flightPhase >= FmgcFlightPhase.Descent || this.aircraftToDescentProfileRelation.isPastTopOfDescent()) + && fcuVerticalMode !== VerticalMode.GS_CPT + && fcuVerticalMode !== VerticalMode.GS_TRACK + && fcuVerticalMode !== VerticalMode.LAND + && fcuVerticalMode !== VerticalMode.FLARE + && fcuVerticalMode !== VerticalMode.ROLL_OUT; + } + + private updateDesModeGuidance() { + const isOnGeometricPath = this.aircraftToDescentProfileRelation.isOnGeometricPath(); + const isAboveSpeedLimitAltitude = this.aircraftToDescentProfileRelation.isAboveSpeedLimitAltitude(); + const isCloseToAirfieldElevation = this.aircraftToDescentProfileRelation.isCloseToAirfieldElevation(); + const isBeforeTopOfDescent = !this.aircraftToDescentProfileRelation.isPastTopOfDescent(); + const linearDeviation = this.aircraftToDescentProfileRelation.computeLinearDeviation(); + const isSpeedAuto = Simplane.getAutoPilotAirspeedManaged(); + const isApproachPhaseActive = this.observer.get().flightPhase === FmgcFlightPhase.Approach; + + 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 || isCloseToAirfieldElevation ? -1000 : -500); + } + } else if (!isOnGeometricPath && isSpeedAuto && !this.isInUnderspeedCondition && !isApproachPhaseActive) { + // 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, flightPhase } = this.observer.get(); + const inManagedSpeed = Simplane.getAutoPilotAirspeedManaged(); + + // In the approach phase we want to fetch the correct speed target from the JS code. + // This is because the guidance logic is a bit different in this case. + const managedSpeedTarget = flightPhase === FmgcFlightPhase.Approach + ? SimVar.GetSimVarValue('L:A32NX_SPEEDS_MANAGED_ATHR', 'knots') + : Math.round(this.iasOrMach(this.aircraftToDescentProfileRelation.currentTargetSpeed(), managedDescentSpeedMach)); + + this.speedTarget = inManagedSpeed + ? managedSpeedTarget + : 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 = 8; + const speedBias = this.requestedVerticalMode === RequestedVerticalMode.SpeedThrust + ? Math.max(Math.min(this.aircraftToDescentProfileRelation.computeLinearDeviation() / 100, maxBias), 0) + : 0; + + const airspeed = SimVar.GetSimVarValue('AIRSPEED INDICATED', 'knots'); + const guidanceTarget = this.useDynamicSpeedTarget() + ? 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 useDynamicSpeedTarget(): boolean { + return this.speedState === DescentSpeedGuidanceState.TargetAndMargins + && (this.requestedVerticalMode === RequestedVerticalMode.SpeedThrust || this.requestedVerticalMode === RequestedVerticalMode.VpathThrust); + } + + 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 00000000000..ac93054d4d6 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/descent/DescentPathBuilder.ts @@ -0,0 +1,304 @@ +import { DescentAltitudeConstraint, VerticalCheckpoint, 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'; +import { AltitudeConstraint, AltitudeConstraintType } from '@fmgc/guidance/lnav/legs'; +import { MathUtils } from '@shared/MathUtils'; + +export class DescentPathBuilder { + private geometricPathBuilder: GeometricPathBuilder; + + private idleDescentStrategy: DescentStrategy; + + constructor( + private computationParametersObserver: VerticalProfileComputationParametersObserver, + atmosphericConditions: AtmosphericConditions, + ) { + this.geometricPathBuilder = new GeometricPathBuilder( + computationParametersObserver, + atmosphericConditions, + ); + + this.idleDescentStrategy = new IdleDescentStrategy(computationParametersObserver, atmosphericConditions); + } + + computeManagedDescentPath( + sequence: TemporaryCheckpointSequence, + profile: BaseGeometryProfile, + speedProfile: SpeedProfile, + windProfile: HeadwindProfile, + cruiseAltitude: Feet, + ) { + const TOL = 10; + const decelPoint: VerticalCheckpoint = { ...sequence.lastCheckpoint }; + + const geometricSequence = new TemporaryCheckpointSequence(decelPoint); + const idleSequence = new TemporaryCheckpointSequence(decelPoint); + + this.buildIdlePath(idleSequence, profile, speedProfile, windProfile, cruiseAltitude); + + for (let i = profile.descentAltitudeConstraints.length - 1; i >= 0; i -= 1) { + const constraintPoint = profile.descentAltitudeConstraints[i]; + + if (constraintPoint.distanceFromStart >= decelPoint.distanceFromStart) { + // If we've found a constraint that's beyond the decel point, we can ignore it. + continue; + } else if (constraintPoint.distanceFromStart < idleSequence.lastCheckpoint.distanceFromStart + || !this.isConstraintBelowCruisingAltitude(constraintPoint.constraint, cruiseAltitude)) { + // If we arrive at the constraints before the idle path, we are done + break; + } + + const altAtConstraint = idleSequence.interpolateAltitudeBackwards(constraintPoint.distanceFromStart); + const [isAltitudeConstraintMet, altitudeToContinueFrom] = evaluateAltitudeConstraint(constraintPoint.constraint, altAtConstraint, TOL); + if (!isAltitudeConstraintMet) { + const geometricPathPoint = { distanceFromStart: constraintPoint.distanceFromStart, altitude: altitudeToContinueFrom }; + + // Plan geometric path between decel point and geometric path point (point between geometric and idle path) + const geometricSegments: PlannedGeometricSegment[] = []; + GeometricPathPlanner.planDescentSegments(profile.descentAltitudeConstraints, decelPoint, geometricPathPoint, geometricSegments, TOL); + + // Execute + geometricSequence.reset(geometricSequence.at(0)); + this.geometricPathBuilder.executeGeometricSegments(geometricSequence, geometricSegments, profile.descentSpeedConstraints.slice(), windProfile); + + idleSequence.reset(geometricSequence.lastCheckpoint); + this.buildIdlePath(idleSequence, profile, speedProfile, windProfile, cruiseAltitude); + } + } + + if (geometricSequence.length > 1) { + geometricSequence.lastCheckpoint.reason = VerticalCheckpointReason.GeometricPathStart; + } else { + geometricSequence.copyLastCheckpoint({ reason: VerticalCheckpointReason.GeometricPathStart }); + } + + sequence.push(...geometricSequence.get()); + sequence.push(...idleSequence.get()); + } + + 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 (altitude >= topOfDescentAltitude) { + break; + } + + 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.predictToSpeed( + altitude, + speedTargetBeforeCurrentPosition, + speed, + managedDescentSpeedMach, + remainingFuelOnBoard, + headwind, + ); + + if (decelerationStep.distanceTraveled > 0) { + throw new Error('[FMS/VNAV] Deceleration step in idle path has positive distance travelled. The final speed and inital speed arguments are probably reversed'); + } + + sequence.addCheckpointFromStep(decelerationStep, VerticalCheckpointReason.IdlePathAtmosphericConditions); + + continue; + } + + const headwind = windProfile.getHeadwindComponent(distanceFromStart, altitude); + const descentStep = this.idleDescentStrategy.predictToDistance( + altitude, + constraint.distanceFromStart - sequence.lastCheckpoint.distanceFromStart, + speed, + managedDescentSpeedMach, + remainingFuelOnBoard, + headwind, + ); + + if (descentStep.finalAltitude > Math.max(topOfDescentAltitude, descentStep.initialAltitude)) { + const scaling = (topOfDescentAltitude - descentStep.initialAltitude) / (descentStep.finalAltitude - descentStep.initialAltitude); + + this.scaleStepBasedOnLastCheckpoint(sequence.lastCheckpoint, descentStep, scaling); + } + + sequence.addCheckpointFromStep(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.predictToSpeed(altitude, speedTarget, speed, managedDescentSpeedMach, remainingFuelOnBoard, headwind); + + if (decelerationStep.distanceTraveled > 0) { + throw new Error('[FMS/VNAV] Deceleration step in idle path has positive distance travelled. The final speed and inital speed arguments are probably reversed'); + } + + // 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.scaleStepBasedOnLastCheckpoint(sequence.lastCheckpoint, decelerationStep, scaling); + } + + sequence.addCheckpointFromStep(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(altitude, startingAltitudeForSegment, speed, managedDescentSpeedMach, remainingFuelOnBoard, headwind); + sequence.addCheckpointFromStep(step, VerticalCheckpointReason.IdlePathAtmosphericConditions); + } + + if (sequence.lastCheckpoint.reason === VerticalCheckpointReason.IdlePathAtmosphericConditions) { + sequence.lastCheckpoint.reason = VerticalCheckpointReason.TopOfDescent; + } else { + sequence.copyLastCheckpoint(({ reason: VerticalCheckpointReason.TopOfDescent })); + } + } + + 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.finalAltitude; + step.speed = (1 - scaling) * lastCheckpoint.speed + scaling * step.speed; + } + + isConstraintBelowCruisingAltitude(constraint: AltitudeConstraint, finalCruiseAltitude: Feet): boolean { + if (constraint.type === AltitudeConstraintType.at) { + return constraint.altitude1 <= finalCruiseAltitude; + } if (constraint.type === AltitudeConstraintType.atOrAbove) { + return constraint.altitude1 <= finalCruiseAltitude; + } if (constraint.type === AltitudeConstraintType.atOrBelow) { + return true; + } if (constraint.type === AltitudeConstraintType.range) { + return constraint.altitude2 <= finalCruiseAltitude; + } + + return true; + } +} + +export class GeometricPathPlanner { + static planDescentSegments( + constraints: DescentAltitudeConstraint[], + start: GeometricPathPoint, + end: GeometricPathPoint, + segments: PlannedGeometricSegment[] = [], + tolerance: number, + ): PlannedGeometricSegment[] { + // A "gradient" is just a quantity of units Feet / NauticalMiles + const gradient = calculateGradient(start, end); + + for (let i = 0; i < constraints.length; i++) { + const constraintPoint = constraints[i]; + + if (constraintPoint.distanceFromStart >= start.distanceFromStart || constraintPoint.distanceFromStart <= end.distanceFromStart) { + continue; + } + + const altAtConstraint = start.altitude + gradient * (constraintPoint.distanceFromStart - start.distanceFromStart); + + const [isAltitudeConstraintMet, altitudeToContinueFrom] = evaluateAltitudeConstraint(constraintPoint.constraint, altAtConstraint, tolerance); + if (!isAltitudeConstraintMet) { + const center = { distanceFromStart: constraintPoint.distanceFromStart, altitude: altitudeToContinueFrom }; + + this.planDescentSegments(constraints, start, center, segments, tolerance); + this.planDescentSegments(constraints, center, end, segments, tolerance); + + return; + } + } + + segments.push({ end, gradient }); + } +} + +function evaluateAltitudeConstraint(constraint: AltitudeConstraint, altitude: Feet, tol: number): [boolean, Feet] { + // Even though in the MCDU constraints count as met if within 250 ft, we use 10 ft here for the initial path construction. + switch (constraint.type) { + case AltitudeConstraintType.at: + return [isAltitudeConstraintMet(constraint, altitude, tol), MathUtils.clamp(altitude, constraint.altitude1, constraint.altitude1)]; + case AltitudeConstraintType.atOrAbove: + return [isAtOrAboveAltitudeConstraintMet(constraint, altitude, tol), Math.max(altitude, constraint.altitude1)]; + case AltitudeConstraintType.atOrBelow: + return [isAtOrBelowAltitudeConstraintMet(constraint, altitude, tol), Math.min(altitude, constraint.altitude1)]; + case AltitudeConstraintType.range: + return [isRangeAltitudeConstraintMet(constraint, altitude, tol), MathUtils.clamp(altitude, constraint.altitude2, constraint.altitude1)]; + default: + console.error('[FMS/VNAV] Invalid altitude constraint type'); + return [true, altitude]; + } +} + +const isAtAltitudeConstraintMet = (constraint: AltitudeConstraint, altitude: Feet, tol: Feet = 250) => Math.abs(altitude - constraint.altitude1) < tol; +const isAtOrAboveAltitudeConstraintMet = (constraint: AltitudeConstraint, altitude: Feet, tol: Feet = 250) => (altitude - constraint.altitude1) > -tol; +const isAtOrBelowAltitudeConstraintMet = (constraint: AltitudeConstraint, altitude: Feet, tol: Feet = 250) => (altitude - constraint.altitude1) < tol; +const isRangeAltitudeConstraintMet = (constraint: AltitudeConstraint, altitude: Feet, tol: Feet = 250) => (altitude - constraint.altitude2) > -tol + && (altitude - constraint.altitude1) < tol; +export const isAltitudeConstraintMet = (constraint: AltitudeConstraint, altitude: Feet, tol: Feet = 250) => { + switch (constraint.type) { + case AltitudeConstraintType.at: + return isAtAltitudeConstraintMet(constraint, altitude, tol); + case AltitudeConstraintType.atOrAbove: + return isAtOrAboveAltitudeConstraintMet(constraint, altitude, tol); + case AltitudeConstraintType.atOrBelow: + return isAtOrBelowAltitudeConstraintMet(constraint, altitude, tol); + case AltitudeConstraintType.range: + return isRangeAltitudeConstraintMet(constraint, altitude, tol); + default: + return true; + } +}; + +export type GeometricPathPoint = { + distanceFromStart: NauticalMiles, + altitude: Feet +} + +export type PlannedGeometricSegment = { + gradient: number, + end: GeometricPathPoint, + isTooSteep?: boolean, +} + +export function calculateGradient(start: GeometricPathPoint, end: GeometricPathPoint): number { + return Math.abs(start.distanceFromStart - end.distanceFromStart) < 1e-9 + ? 0 + : (start.altitude - end.altitude) / (start.distanceFromStart - end.distanceFromStart); +} 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 00000000000..898ed827a5f --- /dev/null +++ b/src/fmgc/src/guidance/vnav/descent/DescentStrategy.ts @@ -0,0 +1,141 @@ +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 step from an initial altitude until the aircraft reaches finalSpeed + * @param initialAltitude + * @param speed + * @param finalSpeed + * @param mach + * @param fuelOnBoard + */ + predictToSpeed( + initialAltitude: 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, + ); + } + + predictToSpeed( + initialAltitude: number, finalSpeed: Knots, speed: Knots, mach: Mach, 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; + + const initialMach = Math.min(this.atmosphericConditions.computeMachFromCas(initialAltitude, speed), mach); + const finalMach = Math.min(this.atmosphericConditions.computeMachFromCas(initialAltitude, finalSpeed), mach); + + return Predictions.speedChangeStep( + -1, + initialAltitude, + speed, + finalSpeed, + initialMach, + finalMach, + predictedN1, + zeroFuelWeight, + fuelOnBoard, + headwindComponent.value, + this.atmosphericConditions.isaDeviation, + tropoPause, + config.gearExtended, + config.flapConfig, + config.speedbrakesExtended, + 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 00000000000..0f25cb27fc4 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/descent/GeometricPathBuilder.ts @@ -0,0 +1,170 @@ +import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions'; +import { FlightPathAngleStrategy } from '@fmgc/guidance/vnav/climb/ClimbStrategy'; +import { FlapConf } from '@fmgc/guidance/vnav/common'; +import { PlannedGeometricSegment } from '@fmgc/guidance/vnav/descent/DescentPathBuilder'; +import { StepResults, VnavStepError } from '@fmgc/guidance/vnav/Predictions'; +import { MaxSpeedConstraint, 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'; +import { MathUtils } from '@shared/MathUtils'; + +export class GeometricPathBuilder { + private flightPathAngleStrategy: FlightPathAngleStrategy; + + constructor( + private observer: VerticalProfileComputationParametersObserver, + atmosphericConditions: AtmosphericConditions, + ) { + this.flightPathAngleStrategy = new FlightPathAngleStrategy(observer, atmosphericConditions, -3.0); + } + + executeGeometricSegments(sequence: TemporaryCheckpointSequence, segments: PlannedGeometricSegment[], speedConstraints: MaxSpeedConstraint[], windProfile: HeadwindProfile) { + for (const segment of segments) { + const currentSegmentSequence = new TemporaryCheckpointSequence(sequence.lastCheckpoint); + + if (!this.executeGeometricSegment(currentSegmentSequence, segment, speedConstraints, windProfile, false)) { + if (!this.executeGeometricSegment(currentSegmentSequence, segment, speedConstraints, windProfile, true)) { + // Marking the segment as too steep, so that we ignore speed constraints on the next try and just fly at the maximum possible gradient + segment.isTooSteep = true; + + this.executeGeometricSegment(currentSegmentSequence, segment, speedConstraints, windProfile, true); + + sequence.push(...currentSegmentSequence.get()); + sequence.copyLastCheckpoint({ + reason: VerticalCheckpointReason.GeometricPathTooSteep, + altitude: segment.end.altitude, + }); + + continue; + } + } + + sequence.push(...currentSegmentSequence.get()); + } + } + + private executeGeometricSegment( + sequence: TemporaryCheckpointSequence, segment: PlannedGeometricSegment, speedConstraints: MaxSpeedConstraint[], windProfile: HeadwindProfile, useSpeedbrakes: boolean, + ) { + const { managedDescentSpeed, managedDescentSpeedMach, descentSpeedLimit } = this.observer.get(); + this.flightPathAngleStrategy.flightPathAngle = MathUtils.RADIANS_TO_DEGREES * Math.atan(segment.gradient / 6076.12); + + let econSpeed = managedDescentSpeed; + + // We treat the speed limit like a constraint if we cross it on this segment + const isSpeedLimitValid = Number.isFinite(descentSpeedLimit.speed) && Number.isFinite(descentSpeedLimit.underAltitude); + const isBelowSpeedLimitAlt = sequence.lastCheckpoint.altitude <= descentSpeedLimit.underAltitude; + if (isSpeedLimitValid && isBelowSpeedLimitAlt) { + if (segment.end.altitude > descentSpeedLimit.underAltitude) { + const distanceToSpeedLimit = (descentSpeedLimit.underAltitude - sequence.lastCheckpoint.altitude) / segment.gradient; + + speedConstraints.push({ + distanceFromStart: sequence.lastCheckpoint.distanceFromStart + distanceToSpeedLimit, + maxSpeed: descentSpeedLimit.speed, + }); + + speedConstraints.sort((a, b) => a.distanceFromStart - b.distanceFromStart); + } else { + econSpeed = Math.min(econSpeed, descentSpeedLimit.speed); + } + } + + for (let i = speedConstraints.length - 1; i >= 0 && !segment.isTooSteep; i--) { + const speedConstraint = speedConstraints[i]; + const maxDistance = segment.end.distanceFromStart - sequence.lastCheckpoint.distanceFromStart; + + if (speedConstraint.distanceFromStart > sequence.lastCheckpoint.distanceFromStart || maxDistance >= 0) { + continue; + } + + let maxSpeed = econSpeed; + for (let j = 0; j <= i; j++) { + const sc = speedConstraints[j]; + + if (sc.distanceFromStart > sequence.lastCheckpoint.distanceFromStart) { + break; + } + + maxSpeed = Math.min(econSpeed, speedConstraint.maxSpeed); + } + + // Decelerate to speed target + const decelerationStep = this.flightPathAngleStrategy.predictToSpeed( + sequence.lastCheckpoint.altitude, + maxSpeed, + sequence.lastCheckpoint.speed, + managedDescentSpeedMach, + sequence.lastCheckpoint.remainingFuelOnBoard, + windProfile.getHeadwindComponent(sequence.lastCheckpoint.distanceFromStart, sequence.lastCheckpoint.altitude), + { speedbrakesExtended: useSpeedbrakes, flapConfig: FlapConf.CLEAN, gearExtended: false }, + ); + + if (decelerationStep.error === VnavStepError.AVAILABLE_GRADIENT_INSUFFICIENT) { + if (!useSpeedbrakes) { + if (VnavConfig.DEBUG_PROFILE) { + console.log(`[FMS/VNAV]: Too steep: ${this.flightPathAngleStrategy.flightPathAngle}°. Trying with speedbrakes.`); + } + + // Break out and try the whole segment with speedbrakes + return false; + } + + if (VnavConfig.DEBUG_PROFILE) { + console.log(`[FMS/VNAV]: Too steep: ${this.flightPathAngleStrategy.flightPathAngle}°.`); + } + + segment.gradient = Math.tan(decelerationStep.pathAngle * MathUtils.DEGREES_TO_RADIANS) * 6076.12; + return false; + } + + // These are negative distances + if (decelerationStep.distanceTraveled < Math.min(maxDistance, 0)) { + const scaling = maxDistance / decelerationStep.distanceTraveled; + + this.scaleStepBasedOnLastCheckpoint(sequence.lastCheckpoint, decelerationStep, scaling); + sequence.addCheckpointFromStep(decelerationStep, VerticalCheckpointReason.SpeedConstraint); + } else if (Math.max(speedConstraint.distanceFromStart, segment.end.distanceFromStart) - sequence.lastCheckpoint.distanceFromStart < 0) { + sequence.addCheckpointFromStep(decelerationStep, VerticalCheckpointReason.AtmosphericConditions); + + // Fly to constraint + const stepToConstraint = this.flightPathAngleStrategy.predictToDistance( + sequence.lastCheckpoint.altitude, + Math.max(speedConstraint.distanceFromStart, segment.end.distanceFromStart) - sequence.lastCheckpoint.distanceFromStart, + sequence.lastCheckpoint.speed, + managedDescentSpeedMach, + sequence.lastCheckpoint.remainingFuelOnBoard, + windProfile.getHeadwindComponent(sequence.lastCheckpoint.distanceFromStart, sequence.lastCheckpoint.altitude), + ); + + sequence.addCheckpointFromStep(stepToConstraint, VerticalCheckpointReason.SpeedConstraint); + } + } + + if (segment.end.distanceFromStart - sequence.lastCheckpoint.distanceFromStart < 0) { + // Fly to end of segment + // TODO: It's possible we are not at the econSpeed yet. If so, we should accelerate to it + const stepToEndOfSegment = this.flightPathAngleStrategy.predictToDistance( + sequence.lastCheckpoint.altitude, + segment.end.distanceFromStart - sequence.lastCheckpoint.distanceFromStart, + sequence.lastCheckpoint.speed, + managedDescentSpeedMach, + sequence.lastCheckpoint.remainingFuelOnBoard, + windProfile.getHeadwindComponent(sequence.lastCheckpoint.distanceFromStart, sequence.lastCheckpoint.altitude), + ); + + sequence.addCheckpointFromStep(stepToEndOfSegment, VerticalCheckpointReason.AtmosphericConditions); + } + + return true; + } + + 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.finalAltitude; + step.speed = (1 - scaling) * lastCheckpoint.speed + scaling * step.speed; + } +} 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 77c1e427f3c..00000000000 --- 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 00000000000..8da32be0909 --- /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 00000000000..89b27f772e2 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/descent/LatchedDescentGuidance.ts @@ -0,0 +1,243 @@ +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 { TodGuidance } from './TodGuidance'; +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 todGuidance: TodGuidance; + + private speedTarget: Knots | Mach; + + // An "overspeed condition" just means we are above the speed margins, not that we are in the red band. + // We use a boolean here for hysteresis + private isInOverspeedCondition: boolean = false; + + constructor( + private aircraftToDescentProfileRelation: AircraftToDescentProfileRelation, + private observer: VerticalProfileComputationParametersObserver, + private atmosphericConditions: AtmosphericConditions, + ) { + this.speedMargin = new SpeedMargin(this.observer); + this.todGuidance = new TodGuidance(this.aircraftToDescentProfileRelation, this.observer, this.atmosphericConditions); + + 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(deltaTime: number, distanceToEnd: NauticalMiles) { + this.aircraftToDescentProfileRelation.update(distanceToEnd); + + 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.todGuidance.update(deltaTime); + } + + 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 00000000000..1e338f02ce2 --- /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[], offset: NauticalMiles = 0): 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 + offset, c2Start.altitude, c2End.distanceFromStart + offset, 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 00000000000..6039deea5d0 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/descent/SpeedMargin.ts @@ -0,0 +1,36 @@ +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 { managedDescentSpeed, managedDescentSpeedMach } = this.observer.get(); + + 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 isMachTarget = managedDescentSpeed - SimVar.GetGameVarValue('FROM MACH TO KIAS', 'number', managedDescentSpeedMach) > 1; + + const distanceToUpperMargin = (!isMachTarget && 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 00000000000..591bee519ba --- /dev/null +++ b/src/fmgc/src/guidance/vnav/descent/TacticalDescentPathBuilder.ts @@ -0,0 +1,180 @@ +import { 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 { 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 TacticalDescentPathBuilder { + constructor(private observer: VerticalProfileComputationParametersObserver) { } + + /** + * Builds a path from the last checkpoint to the finalAltitude + * @param profile + * @param descentStrategy + * @param speedProfile + * @param windProfile + * @param finalAltitude + */ + buildTacticalDescentPath(profile: BaseGeometryProfile, descentStrategy: DescentStrategy, speedProfile: SpeedProfile, windProfile: HeadwindProfile, finalAltitude: Feet) { + const constraintsToUse = profile.descentSpeedConstraints + .slice() + .reverse() + .sort((a, b) => a.distanceFromStart - b.distanceFromStart); + + const { descentSpeedLimit, managedDescentSpeedMach } = this.observer.get(); + if (speedProfile.shouldTakeDescentSpeedLimitIntoAccount() && finalAltitude < descentSpeedLimit.underAltitude && profile.lastCheckpoint.speed > descentSpeedLimit.speed) { + let descentSegment = new TemporaryCheckpointSequence(profile.lastCheckpoint); + let decelerationSegment: StepResults = null; + + const tryDecelDistance = (speedLimitDecelAltitude: Feet): Feet => { + descentSegment = this.buildToAltitude(profile.lastCheckpoint, descentStrategy, constraintsToUse, windProfile, speedLimitDecelAltitude); + + // If we've already decelerated below the constraint speed due to a previous constraint, we don't need to decelerate anymore. + // We return 0 because the bisection method will terminate if the error is zero. + if (descentSegment.lastCheckpoint.speed <= descentSpeedLimit.speed) { + return 0; + } + + decelerationSegment = descentStrategy.predictToSpeed( + descentSegment.lastCheckpoint.altitude, + Math.min(descentSegment.lastCheckpoint.speed, descentSpeedLimit.speed), + descentSegment.lastCheckpoint.speed, + managedDescentSpeedMach, + descentSegment.lastCheckpoint.remainingFuelOnBoard, + windProfile.getHeadwindComponent(descentSegment.lastCheckpoint.distanceFromStart, descentSegment.lastCheckpoint.altitude), + ); + + const altitudeOvershoot = decelerationSegment.finalAltitude - descentSpeedLimit.underAltitude; + + return altitudeOvershoot; + }; + + bisectionMethod(tryDecelDistance, descentSpeedLimit.underAltitude, Math.min(descentSpeedLimit.underAltitude + 6000, profile.lastCheckpoint.altitude), [-10, 500], 10); + + // If we returned early, it's possible that it was not necessary to compute a `decelerationSegment`, so this would be null + if (decelerationSegment) { + descentSegment.addCheckpointFromStep(decelerationSegment, VerticalCheckpointReason.AtmosphericConditions); + } + + profile.checkpoints.push(...descentSegment.get()); + } + + const sequenceToFinalAltitude = this.buildToAltitude(profile.lastCheckpoint, descentStrategy, constraintsToUse, windProfile, finalAltitude); + + profile.checkpoints.push(...sequenceToFinalAltitude.get()); + } + + private buildToAltitude( + start: VerticalCheckpoint, descentStrategy: DescentStrategy, constraints: MaxSpeedConstraint[], windProfile: HeadwindProfile, finalAltitude: Feet, + ): TemporaryCheckpointSequence { + const sequence = new TemporaryCheckpointSequence(start); + const { managedDescentSpeedMach } = this.observer.get(); + + for (const constraint of constraints) { + this.handleSpeedConstraint(sequence, constraint, descentStrategy, windProfile); + + if (sequence.lastCheckpoint.altitude <= finalAltitude) { + while (sequence.checkpoints.length > 1 && sequence.lastCheckpoint.altitude <= finalAltitude) { + sequence.checkpoints.splice(-1, 1); + } + + break; + } + } + + if (sequence.lastCheckpoint.altitude > finalAltitude) { + const descentSegment = descentStrategy.predictToAltitude( + sequence.lastCheckpoint.altitude, + finalAltitude, + sequence.lastCheckpoint.speed, + managedDescentSpeedMach, + sequence.lastCheckpoint.remainingFuelOnBoard, + windProfile.getHeadwindComponent(sequence.lastCheckpoint.distanceFromStart, sequence.lastCheckpoint.altitude), + ); + + sequence.addCheckpointFromStep(descentSegment, VerticalCheckpointReason.CrossingFcuAltitudeDescent); + } + + return sequence; + } + + private handleSpeedConstraint(sequence: TemporaryCheckpointSequence, constraint: MaxSpeedConstraint, descentStrategy: DescentStrategy, windProfile: HeadwindProfile) { + if (sequence.lastCheckpoint.speed <= constraint.maxSpeed && sequence.lastCheckpoint.distanceFromStart > constraint.distanceFromStart) { + return; + } + + const { managedDescentSpeedMach } = this.observer.get(); + + const distanceToConstraint = constraint.distanceFromStart - sequence.lastCheckpoint.distanceFromStart; + const maxDecelerationDistance = Math.min(distanceToConstraint, 20); + + let descentSegment: StepResults = null; + let decelerationSegment: StepResults = null; + + const tryDecelDistance = (decelerationSegmentDistance: NauticalMiles): NauticalMiles => { + descentSegment = descentStrategy.predictToDistance( + sequence.lastCheckpoint.altitude, + distanceToConstraint - decelerationSegmentDistance, + sequence.lastCheckpoint.speed, + managedDescentSpeedMach, + sequence.lastCheckpoint.remainingFuelOnBoard, + windProfile.getHeadwindComponent(sequence.lastCheckpoint.distanceFromStart, sequence.lastCheckpoint.altitude), + ); + + decelerationSegment = descentStrategy.predictToSpeed( + descentSegment.finalAltitude, + constraint.maxSpeed, + descentSegment.speed, + managedDescentSpeedMach, + sequence.lastCheckpoint.remainingFuelOnBoard - descentSegment.fuelBurned, + windProfile.getHeadwindComponent(sequence.lastCheckpoint.distanceFromStart + descentSegment.distanceTraveled, descentSegment.finalAltitude), + ); + + const totalDistanceTravelled = sequence.lastCheckpoint.distanceFromStart + descentSegment.distanceTraveled + decelerationSegment.distanceTraveled; + const distanceOvershoot = totalDistanceTravelled - distanceToConstraint; + + return distanceOvershoot; + }; + + const a = 0; + const b = maxDecelerationDistance; + + bisectionMethod(tryDecelDistance, a, b); + + sequence.addCheckpointFromStep(descentSegment, VerticalCheckpointReason.AtmosphericConditions); + sequence.addCheckpointFromStep(decelerationSegment, VerticalCheckpointReason.SpeedConstraint); + } +} + +function bisectionMethod(f: (c: number) => number, a: number, b: number, errorTolerance: [number, number] = [-0.05, 0.05], maxIterations: number = 10): number { + let fa = f(a); + if (fa > 0) { + let i = 0; + while (i++ < maxIterations) { + const c = (a + b) / 2; + const fc = f(c); + + if (fc >= errorTolerance[0] || fc <= errorTolerance[1]) { + if (VnavConfig.DEBUG_PROFILE) { + console.log(`[FMS/VNAV] Final error ${fc} after ${i} iterations.`); + } + + return c; + } + + if (fa * fc > 0) { + a = c; + } else { + b = c; + } + + fa = f(a); + } + } + + return a; +} 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 01a35990fa8..00000000000 --- 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/descent/TodGuidance.ts b/src/fmgc/src/guidance/vnav/descent/TodGuidance.ts new file mode 100644 index 00000000000..3fe698ce10f --- /dev/null +++ b/src/fmgc/src/guidance/vnav/descent/TodGuidance.ts @@ -0,0 +1,106 @@ +import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions'; +import { AircraftToDescentProfileRelation } from '@fmgc/guidance/vnav/descent/AircraftToProfileRelation'; +import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters'; +import { LateralMode } from '@shared/autopilot'; +import { FmgcFlightPhase } from '@shared/flightphase'; +import { NXDataStore } from '@shared/persistence'; +import { PopUp } from '@shared/popup'; + +const TIMEOUT = 10_000; + +export class TodGuidance { + private tdReached: boolean; + + private tdPaused: boolean; + + private apEngaged: boolean; + + private cooldown: number; + + constructor( + private aircraftToDescentProfileRelation: AircraftToDescentProfileRelation, + private observer: VerticalProfileComputationParametersObserver, + private atmosphericConditions: AtmosphericConditions, + ) { + this.cooldown = 0; + this.apEngaged = false; + this.tdReached = false; + this.tdPaused = false; + } + + showPausePopup(title: string, message: string) { + this.cooldown = TIMEOUT; + SimVar.SetSimVarValue('K:PAUSE_SET', 'number', 1); + let popup = new PopUp(); + popup.showInformation(title, message, 'small', + () => { + SimVar.SetSimVarValue('K:PAUSE_SET', 'number', 0); + this.cooldown = TIMEOUT; + popup = null; + }); + } + + update(deltaTime: number) { + this.updateTdReached(deltaTime); + this.updateTdPause(deltaTime); + } + + updateTdPause(deltaTime: number) { + if ( + this.cooldown <= 0 + && NXDataStore.get('PAUSE_AT_TOD', 'DISABLED') === 'ENABLED' + ) { + // Only watching if T/D pause untriggered + between flight phase CLB and CRZ + if (!this.tdPaused + && this.observer.get().flightPhase >= FmgcFlightPhase.Climb + && this.observer.get().flightPhase <= FmgcFlightPhase.Cruise + && Simplane.getAutoPilotAirspeedManaged() + ) { + // Check T/D pause first, then AP mode reversion + if ((this.aircraftToDescentProfileRelation.distanceToTopOfDescent() ?? Number.POSITIVE_INFINITY) < parseFloat(NXDataStore.get('PAUSE_AT_TOD_DISTANCE', '10'))) { + this.tdPaused = true; + this.showPausePopup( + 'TOP OF DESCENT', + `Paused before the calculated top of descent. System Time was ${new Date().toLocaleTimeString()}.`, + ); + // Only guard AP above transitional altitude + } else if (this.atmosphericConditions.currentAltitude ? this.atmosphericConditions.currentAltitude > this.observer.get().originTransitionAltitude : false) { + const apActive = SimVar.GetSimVarValue('L:A32NX_AUTOPILOT_ACTIVE', 'boolean') && SimVar.GetSimVarValue('L:A32NX_FMA_LATERAL_MODE', 'Enum') === LateralMode.NAV; + + if (this.apEngaged && !apActive) { + this.showPausePopup( + 'AP PROTECTION', + `Autopilot or lateral guidance disengaged before the calculated top of descent. System Time was ${new Date().toLocaleTimeString()}.`, + ); + } + + if (this.apEngaged !== apActive) { + this.apEngaged = apActive; + } + } + } + + // Reset flags on turnaround + if (this.observer.get().flightPhase === FmgcFlightPhase.Done || this.observer.get().flightPhase === FmgcFlightPhase.Preflight) { + this.tdPaused = false; + this.apEngaged = false; + } + } + + if (this.cooldown > 0) { + this.cooldown = Math.max(0, this.cooldown - deltaTime); + } + } + + updateTdReached(_deltaTime: number) { + const tdReached = this.observer.get().flightPhase >= FmgcFlightPhase.Climb + && this.observer.get().flightPhase <= FmgcFlightPhase.Cruise + && Simplane.getAutoPilotAirspeedManaged() + && this.aircraftToDescentProfileRelation.isPastTopOfDescent(); + + if (tdReached !== this.tdReached) { + this.tdReached = tdReached; + SimVar.SetSimVarValue('L:A32NX_PFD_MSG_TD_REACHED', 'boolean', this.tdReached); + } + } +} 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 00000000000..1e5b8bd8568 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/profile/BaseGeometryProfile.ts @@ -0,0 +1,458 @@ +import { Common } from '@fmgc/guidance/vnav/common'; +import { PseudoWaypointFlightPlanInfo } from '@fmgc/guidance/PseudoWaypoint'; +import { + GeographicCruiseStep, + 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 cruiseSteps(): GeographicCruiseStep[]; + + 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, snapReverse: boolean = false, + ) { + 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 (!snapReverse && (indexValue <= keySelector(this.checkpoints[i]) && indexValue > keySelector(this.checkpoints[i + 1])) + || snapReverse && (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); + } + + /** + * + * @param altitude Altitude to interpolate from + * @param snapReverse True if we are looking for the first distance at which the altitude is reached or the last. (Think of a level segment) + * @returns + */ + interpolateDistanceAtAltitudeBackwards(altitude: Feet, snapReverse: boolean = false): NauticalMiles { + return this.interpolateFromCheckpointsBackwards( + altitude, + (checkpoint) => Math.round(checkpoint.altitude), + (checkpoint) => checkpoint.distanceFromStart, + snapReverse, + ); + } + + 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]; + } + + 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): VerticalCheckpoint { + 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 this.checkpoints[0]; + } + + 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[i + 1]; + } + } + + 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, + }); + + return this.lastCheckpoint; + } + + 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, + }; + } + + 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 00000000000..5c918dc9ea1 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/profile/NavGeometryProfile.ts @@ -0,0 +1,282 @@ +import { BaseGeometryProfile } from '@fmgc/guidance/vnav/profile/BaseGeometryProfile'; +import { ConstraintReader } from '@fmgc/guidance/vnav/ConstraintReader'; +import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions'; +import { FlightPlans } from '@fmgc/flightplanning/FlightPlanManager'; +import { GuidanceController } from '@fmgc/guidance/GuidanceController'; +import { isAltitudeConstraintMet } from '@fmgc/guidance/vnav/descent/DescentPathBuilder'; +import { + AltitudeConstraint, + AltitudeConstraintType, + getAltitudeConstraintFromWaypoint, + getSpeedConstraintFromWaypoint, + 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, + estimatedFuelOnBoard: Pounds +} + +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', + ContinueDescentArmed = 'ContinueDescentArmed', + 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 interface GeographicCruiseStep { + distanceFromStart: NauticalMiles, + toAltitude: Feet, + waypointIndex: string, + isIgnored: boolean, +} + +export class NavGeometryProfile extends BaseGeometryProfile { + public waypointPredictions: Map = new Map(); + + constructor( + public guidanceControler: GuidanceController, + 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; + } + + override get cruiseSteps(): GeographicCruiseStep[] { + return this.constraintReader.cruiseSteps; + } + + 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]; + } + + get finalDescentAngle(): Degrees { + return this.constraintReader.finalDescentAngle; + } + + get fafDistanceToEnd(): NauticalMiles { + return this.constraintReader.fafDistanceToEnd; + } + + 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(); + const fpm = this.guidanceControler.flightPlanManager; + + if (!this.isReadyToDisplay) { + return predictions; + } + + const topOfDescent = this.findVerticalCheckpoint(VerticalCheckpointReason.TopOfDescent); + + for (let i = this.guidanceControler.activeLegIndex - 1; i < fpm.getWaypointsCount(FlightPlans.Active); i++) { + const waypoint = fpm.getWaypoint(i, FlightPlans.Active); + if (!waypoint) { + continue; + } + + const distanceFromStart = this.getDistanceFromStart(waypoint.additionalData.distanceToEnd); + const { secondsFromPresent, altitude, speed, mach, remainingFuelOnBoard } = this.interpolateEverythingFromStart(distanceFromStart); + + const altitudeConstraint = getAltitudeConstraintFromWaypoint(waypoint); + const speedConstraint = getSpeedConstraintFromWaypoint(waypoint); + + predictions.set(i, { + waypointIndex: i, + distanceFromStart, + secondsFromPresent, + altitude, + speed: this.atmosphericConditions.casOrMach(speed, mach, altitude), + altitudeConstraint, + isAltitudeConstraintMet: altitudeConstraint && isAltitudeConstraintMet(altitudeConstraint, altitude, 250), + speedConstraint, + isSpeedConstraintMet: this.isSpeedConstraintMet(speed, speedConstraint), + altError: this.computeAltError(altitude, altitudeConstraint), + distanceToTopOfDescent: topOfDescent ? topOfDescent.distanceFromStart - distanceFromStart : null, + estimatedFuelOnBoard: remainingFuelOnBoard, + }); + } + + return predictions; + } + + 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(); + } + + invalidate(): void { + this.isReadyToDisplay = false; + this.waypointPredictions = new Map(); + } + + getDistanceFromStart(distanceFromEnd: NauticalMiles): NauticalMiles { + return this.constraintReader.totalFlightPlanDistance - distanceFromEnd; + } + + override resetAltitudeConstraints() { + this.constraintReader.climbAlitudeConstraints = []; + this.constraintReader.descentAltitudeConstraints = []; + } +} 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 00000000000..bd57a24ccf5 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/profile/SelectedGeometryProfile.ts @@ -0,0 +1,38 @@ +import { BaseGeometryProfile } from '@fmgc/guidance/vnav/profile/BaseGeometryProfile'; +import { + DescentAltitudeConstraint, + GeographicCruiseStep, + 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 cruiseSteps: GeographicCruiseStep[] = []; + + 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 00000000000..78e6ac5a8c4 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/profile/TemporaryCheckpointSequence.ts @@ -0,0 +1,78 @@ +import { Common } from '@fmgc/guidance/vnav/common'; +import { StepResults } from '@fmgc/guidance/vnav/Predictions'; +import { VerticalCheckpoint, VerticalCheckpointReason } from '@fmgc/guidance/vnav/profile/NavGeometryProfile'; + +export class TemporaryCheckpointSequence { + checkpoints: VerticalCheckpoint[]; + + constructor(...checkpoints: VerticalCheckpoint[]) { + this.checkpoints = checkpoints; + } + + at(index: number): VerticalCheckpoint { + return this.checkpoints[index]; + } + + get length(): number { + return this.checkpoints.length; + } + + reset(...checkpoints: VerticalCheckpoint[]): void { + this.checkpoints = checkpoints; + } + + undoLastStep() { + this.checkpoints.splice(this.checkpoints.length - 1); + } + + 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); + } + + interpolateAltitudeBackwards(distanceFromStart: NauticalMiles): Feet { + if (distanceFromStart >= this.checkpoints[0].distanceFromStart) { + return this.checkpoints[0].altitude; + } + + for (let i = 1; i < this.checkpoints.length - 1; i++) { + if (distanceFromStart >= this.checkpoints[i].distanceFromStart) { + return Common.interpolate( + distanceFromStart, + this.checkpoints[i - 1].distanceFromStart, + this.checkpoints[i].distanceFromStart, + this.checkpoints[i - 1].altitude, + this.checkpoints[i].altitude, + ); + } + } + + return this.lastCheckpoint.altitude; + } +} 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 00000000000..e4d50790911 --- /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 00000000000..7ab54f03c6b --- /dev/null +++ b/src/fmgc/src/guidance/vnav/wind/AircraftHeadingProfile.ts @@ -0,0 +1,74 @@ +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 | null +} + +export class NavHeadingProfile implements AircraftHeadingProfile { + private courses: CourseAtDistance[] = []; + + constructor(private flightPlanManager: FlightPlanManager) { } + + get(distanceFromStart: NauticalMiles): DegreesTrue | null { + if (this.courses.length === 0) { + return null; + } + + 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 00000000000..9536c248d17 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/wind/ClimbWindProfile.ts @@ -0,0 +1,63 @@ +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'; + +export class ClimbWindProfile implements WindProfile { + constructor( + 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 this.interpolateVectors(this.inputs.climbWinds[i].vector, this.inputs.climbWinds[i + 1].vector, scaling); + } + } + + return this.inputs.climbWinds[this.inputs.climbWinds.length - 1].vector; + } + + getHeadwindComponent(distanceFromStart: NauticalMiles, altitude: Feet, planeHeading: DegreesTrue): WindComponent { + const hasForecast = this.inputs.climbWinds.length !== 0; + const measurement = this.measurementDevice.get(); + const hasMeasurement = measurement !== null; + + if (!hasForecast) { + if (!hasMeasurement) { + return this.inputs.tripWind; + } + + return WindComponent.fromVector(measurement, planeHeading); + } + + const forecast = this.interpolateByAltitude(altitude); + const distanceToAirplane = distanceFromStart - this.aircraftDistanceFromStart; + if (!hasMeasurement || distanceToAirplane < 0) { + return WindComponent.fromVector(forecast, planeHeading); + } + + const scaling = Math.min(1, distanceToAirplane / 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 00000000000..6b79c309ccd --- /dev/null +++ b/src/fmgc/src/guidance/vnav/wind/CruiseWindProfile.ts @@ -0,0 +1,65 @@ +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'; + +// 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 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 this.interpolateVectors(this.inputs.climbWinds[i].vector, this.inputs.climbWinds[i + 1].vector, scaling); + } + } + + return this.inputs.climbWinds[this.inputs.climbWinds.length - 1].vector; + } + + getHeadwindComponent(distanceFromStart: NauticalMiles, altitude: Feet, planeHeading: DegreesTrue): WindComponent { + const hasForecast = this.inputs.climbWinds.length !== 0; + const measurement = this.measurementDevice.get(); + const hasMeasurement = measurement !== null; + + if (!hasForecast) { + if (!hasMeasurement) { + return this.inputs.tripWind; + } + + return WindComponent.fromVector(measurement, planeHeading); + } + + const forecast = this.interpolateByAltitude(altitude); + const distanceToAirplane = distanceFromStart - this.aircraftDistanceFromStart; + if (!hasMeasurement || distanceToAirplane < 0) { + return WindComponent.fromVector(forecast, planeHeading); + } + + const scaling = Math.min(1, distanceToAirplane / 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 00000000000..45ded8fe83a --- /dev/null +++ b/src/fmgc/src/guidance/vnav/wind/DescentWindProfile.ts @@ -0,0 +1,63 @@ +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'; + +export class DescentWindProfile implements WindProfile { + constructor( + 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 this.interpolateVectors(this.inputs.descentWinds[i].vector, this.inputs.descentWinds[i + 1].vector, scaling); + } + } + + return this.inputs.descentWinds[this.inputs.descentWinds.length - 1].vector; + } + + getHeadwindComponent(distanceFromStart: NauticalMiles, altitude: Feet, planeHeading: DegreesTrue): WindComponent { + const hasForecast = this.inputs.descentWinds.length !== 0; + const measurement = this.measurementDevice.get(); + const hasMeasurement = measurement !== null; + + if (!hasForecast) { + if (!hasMeasurement) { + return this.inputs.tripWind; + } + + return WindComponent.fromVector(measurement, planeHeading); + } + + const forecast = this.interpolateByAltitude(altitude); + const distanceToAirplane = distanceFromStart - this.aircraftDistanceFromStart; + if (!hasMeasurement || distanceToAirplane < 0) { + return WindComponent.fromVector(forecast, planeHeading); + } + + const scaling = Math.min(1, distanceToAirplane / 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 00000000000..73b381370b8 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/wind/HeadwindProfile.ts @@ -0,0 +1,25 @@ +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); + if (heading === null) { + return WindComponent.zero(); + } + + 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 00000000000..a30f70454fd --- /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 00000000000..d0a741fdf53 --- /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 00000000000..b8aa574fe0f --- /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 windSpeed = Arinc429Word.fromSimVarValue(`L:A32NX_ADIRS_IR_${this.irsIndex}_WIND_SPEED`); + + if (!windDirection.isNormalOperation() || !windSpeed.isNormalOperation()) { + return null; + } + + return new WindVector(windDirection.value, windSpeed.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 00000000000..d7565eb498c --- /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 00000000000..dfd16cd3291 --- /dev/null +++ b/src/fmgc/src/guidance/vnav/wind/WindProfileFactory.ts @@ -0,0 +1,51 @@ +import { Fmgc } from '@fmgc/guidance/GuidanceController'; +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(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.windInputObserver.get(), + this.windObserver, + this.aircraftDistanceFromStart, + ); + } + + getCruiseWinds(): CruiseWindProfile { + return new CruiseWindProfile( + this.windInputObserver.get(), + this.windObserver, + this.aircraftDistanceFromStart, + ); + } + + getDescentWinds(): DescentWindProfile { + return new DescentWindProfile( + 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 00000000000..f2b1b0f7bb3 --- /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 00000000000..feb176def09 --- /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 b704ffa66fb..54e861e50a2 100644 --- a/src/fmgc/src/index.ts +++ b/src/fmgc/src/index.ts @@ -7,8 +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 { DecelPathBuilder } from './guidance/vnav/descent/DecelPathBuilder'; +import { DescentPathBuilder } from './guidance/vnav/descent/DescentPathBuilder'; import { VerticalFlightPlanBuilder } from './guidance/vnav/verticalFlightPlan/VerticalFlightPlanBuilder'; import { initComponents, updateComponents, recallMessageById } from './components'; import { WaypointBuilder } from './flightplanning/WaypointBuilder'; @@ -34,8 +33,7 @@ export { updateFmgcLoop, recallMessageById, EfisSymbols, - DescentBuilder, - DecelPathBuilder, + DescentPathBuilder, VerticalFlightPlanBuilder, WaypointBuilder, normaliseApproachName, diff --git a/src/fmgc/src/utils/TimeUtils.ts b/src/fmgc/src/utils/TimeUtils.ts new file mode 100644 index 00000000000..a41cf9e9a6c --- /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 350d56de1b2..ca1135bb228 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/EFB/Settings/Pages/RealismPage.tsx b/src/instruments/src/EFB/Settings/Pages/RealismPage.tsx index 159bf6755e5..8c5f01a3e1d 100644 --- a/src/instruments/src/EFB/Settings/Pages/RealismPage.tsx +++ b/src/instruments/src/EFB/Settings/Pages/RealismPage.tsx @@ -19,6 +19,8 @@ export const RealismPage = () => { const [dmcSelfTestTime, setDmcSelfTestTime] = usePersistentProperty('CONFIG_SELF_TEST_TIME', '12'); const [mcduInput, setMcduInput] = usePersistentProperty('MCDU_KB_INPUT', 'DISABLED'); const [mcduTimeout, setMcduTimeout] = usePersistentProperty('CONFIG_MCDU_KB_TIMEOUT', '60'); + const [pauseAtTod, setPauseAtTod] = usePersistentProperty('PAUSE_AT_TOD', 'DISABLED'); + const [todOffset, setTodOffset] = usePersistentNumberProperty('PAUSE_AT_TOD_DISTANCE', 10); const [boardingRate, setBoardingRate] = usePersistentProperty('CONFIG_BOARDING_RATE', 'REAL'); const [realisticTiller, setRealisticTiller] = usePersistentNumberProperty('REALISTIC_TILLER_ENABLED', 0); const [homeCockpit, setHomeCockpit] = usePersistentProperty('HOME_COCKPIT_ENABLED', '0'); @@ -136,6 +138,27 @@ export const RealismPage = () => { setFirstOfficerAvatar(value ? 1 : 0)} /> + + + setPauseAtTod(value ? 'ENABLED' : 'DISABLED')} /> + + {pauseAtTod === 'ENABLED' && ( + + { + if (!Number.isNaN(event) && parseInt(event) >= 0 && parseInt(event) <= 50.0) { + setTodOffset(parseFloat(event.trim())); + } + }} + /> + + )} + ); }; diff --git a/src/instruments/src/ND/elements/FlightPlan.tsx b/src/instruments/src/ND/elements/FlightPlan.tsx index 10133087137..e45450cddd3 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,29 +388,29 @@ const SymbolMarker: FC = memo(({ ident, x, y, endX, endY, arc showIdent = false; elements.push( <> - + - + , ); } else if (type & (NdSymbolTypeFlags.PwpCdaFlap1)) { showIdent = false; elements.push( <> - - + + - 1 + 1 , ); } else if (type & (NdSymbolTypeFlags.PwpCdaFlap2)) { showIdent = false; elements.push( <> - - + + - 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 64ff296cf94..4e041120b6b 100644 --- a/src/instruments/src/ND/elements/TrackLine.tsx +++ b/src/instruments/src/ND/elements/TrackLine.tsx @@ -1,13 +1,42 @@ 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[], + ndRange: number, +} + +export const TrackLine: React.FC = memo(({ x, y, heading, track, mapParams, groundSpeed, symbols, ndRange }) => { const rotate = MathUtils.diffAngle(heading, track); + const [lastUpdateTime, setLastUpdateTime] = useState(Date.now()); + + useEffect(() => { + setLastUpdateTime(Date.now()); + }, [symbols]); return ( - - + + + + {symbols.map((symbol) => { + // We only want to place the symbol on the track line if it does not have a location on the flight plan. + if (!symbol.distanceFromAirplane || symbol.location) { + 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 83f9c8ee327..f83a765b1d2 100644 --- a/src/instruments/src/ND/pages/ArcMode.tsx +++ b/src/instruments/src/ND/pages/ArcMode.tsx @@ -82,7 +82,16 @@ 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 cda40cec103..d6af32c18e6 100644 --- a/src/instruments/src/ND/pages/RoseMode.tsx +++ b/src/instruments/src/ND/pages/RoseMode.tsx @@ -81,7 +81,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 a545f0acda7..c59e0497501 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-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 7b76045bc70..9c1053b2c43 100644 --- a/src/instruments/src/PFD/FMA.tsx +++ b/src/instruments/src/PFD/FMA.tsx @@ -48,6 +48,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); @@ -66,7 +68,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(), this.fcdcDiscreteWord1, this.fwcFlightPhase)[0] !== null; + this.setHoldSpeed, this.trkFpaDeselected.get(), this.tcasRaInhibited.get(), this.fcdcDiscreteWord1, this.fwcFlightPhase, this.tdReached)[0] !== null; const engineMessage = this.athrModeMessage; const AB3Message = (this.machPreselVal !== -1 @@ -148,6 +150,10 @@ export class FMA extends DisplayComponent<{ bus: EventBus, isAttExcessive: Subsc sub.on('fwcFlightPhase').whenChanged().handle((fwcFlightPhase) => { this.fwcFlightPhase = fwcFlightPhase; + }); + + sub.on('tdReached').whenChanged().handle((tdr) => { + this.tdReached = tdr; this.handleFMABorders(); }); } @@ -1179,6 +1185,7 @@ const getBC3Message = ( tcasRaInhibited: boolean, fcdcWord1: Arinc429Word, fwcFlightPhase: number, + tdReached: boolean, ) => { const armedVerticalBitmask = armedVerticalMode; const TCASArmed = (armedVerticalBitmask >> 6) & 1; @@ -1217,7 +1224,7 @@ const getBC3Message = ( } 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) { @@ -1267,9 +1274,11 @@ class BC3Cell extends DisplayComponent<{ isAttExcessive: Subscribable, private fwcFlightPhase = 0; + private tdReached = false; + private fillBC3Cell() { const [text, className] = getBC3Message( - this.isAttExcessive, this.armedVerticalMode, this.setHoldSpeed, this.trkFpaDeselected, this.tcasRaInhibited, this.fcdcDiscreteWord1, this.fwcFlightPhase, + this.isAttExcessive, this.armedVerticalMode, this.setHoldSpeed, this.trkFpaDeselected, this.tcasRaInhibited, this.fcdcDiscreteWord1, this.fwcFlightPhase, this.tdReached, ); this.classNameSub.set(`FontMedium MiddleAlign ${className}`); if (text !== null) { @@ -1316,6 +1325,10 @@ class BC3Cell extends DisplayComponent<{ isAttExcessive: Subscribable, sub.on('fwcFlightPhase').whenChanged().handle((fwcFlightPhase) => { this.fwcFlightPhase = fwcFlightPhase; + }); + + sub.on('tdReached').whenChanged().handle((tdr) => { + this.tdReached = tdr; this.fillBC3Cell(); }); } diff --git a/src/instruments/src/PFD/LinearDeviationIndicator.tsx b/src/instruments/src/PFD/LinearDeviationIndicator.tsx new file mode 100644 index 00000000000..06fbaf6a536 --- /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(5).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 5ed3fa96f2a..7fdfe8183a1 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 64dc6e6ba45..dfc5dd2926c 100644 --- a/src/instruments/src/PFD/SpeedIndicator.tsx +++ b/src/instruments/src/PFD/SpeedIndicator.tsx @@ -991,11 +991,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 c6cccb6f92d..ce457e278df 100644 --- a/src/instruments/src/PFD/instrument.tsx +++ b/src/instruments/src/PFD/instrument.tsx @@ -149,6 +149,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('trkFpaDeselectedTCAS'); this.simVarPublisher.subscribe('tcasRaInhibited'); this.simVarPublisher.subscribe('groundSpeed'); @@ -220,6 +221,13 @@ class A32NX_PFD extends BaseInstrument { this.simVarPublisher.subscribe('fwc1AltAlertFlashing'); this.simVarPublisher.subscribe('fwc2AltAlertFlashing'); + 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 2c2b068d69a..8863473e484 100644 --- a/src/instruments/src/PFD/shared/PFDSimvarPublisher.tsx +++ b/src/instruments/src/PFD/shared/PFDSimvarPublisher.tsx @@ -83,6 +83,7 @@ export interface PFDSimvars { engTwoRunning: boolean; expediteMode: boolean; setHoldSpeed: boolean; + tdReached: boolean; trkFpaDeselectedTCAS: boolean; tcasRaInhibited: boolean; groundSpeed: number; @@ -148,6 +149,12 @@ export interface PFDSimvars { fwc2AltAlertPulsing: boolean, fwc1AltAlertFlashing: boolean, fwc2AltAlertFlashing: boolean, + linearDeviationActive: boolean; + targetAltitude: number; + verticalProfileLatched: boolean; + showSpeedMargins: boolean; + upperSpeedMargin: number; + lowerSpeedMargin: number; } export enum PFDVars { @@ -232,6 +239,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', trkFpaDeselectedTCAS= 'L:A32NX_AUTOPILOT_TCAS_MESSAGE_TRK_FPA_DESELECTION', tcasRaInhibited = 'L:A32NX_AUTOPILOT_TCAS_MESSAGE_RA_INHIBITED', groundSpeed = 'L:A32NX_ADIRS_IR_1_GROUND_SPEED', @@ -297,6 +305,12 @@ export enum PFDVars { fwc2AltAlertPulsing = 'L:A32NX_FWS_FWC_2_ALT_ALERT_PULSING', fwc1AltAlertFlashing = 'L:A32NX_FWS_FWC_1_ALT_ALERT_FLASHING', fwc2AltAlertFlashing = 'L:A32NX_FWS_FWC_2_ALT_ALERT_FLASHING', + 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. */ @@ -383,6 +397,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 }], ['trkFpaDeselectedTCAS', { name: PFDVars.trkFpaDeselectedTCAS, type: SimVarValueType.Bool }], ['tcasRaInhibited', { name: PFDVars.tcasRaInhibited, type: SimVarValueType.Bool }], ['groundSpeed', { name: PFDVars.groundSpeed, type: SimVarValueType.Number }], @@ -448,6 +463,12 @@ export class PFDSimvarPublisher extends SimVarPublisher { ['fwc2AltAlertPulsing', { name: PFDVars.fwc2AltAlertPulsing, type: SimVarValueType.Bool }], ['fwc1AltAlertFlashing', { name: PFDVars.fwc1AltAlertFlashing, type: SimVarValueType.Bool }], ['fwc2AltAlertFlashing', { name: PFDVars.fwc2AltAlertFlashing, 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 10bfef17c4c..595f23042db 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 b8faf52595f..d2024a7d8ed 100644 --- a/src/shared/src/FmMessages.ts +++ b/src/shared/src/FmMessages.ts @@ -185,4 +185,22 @@ export const FMMessageTypes: Readonly> = { text: 'TURN AREA EXCEEDANCE', color: 'Amber', }, + TdReached: { + id: 17, + text: 'T/D REACHED', + color: 'White', + clearable: true, + }, + StepAhead: { + id: 18, + text: 'STEP AHEAD', + color: 'White', + clearable: true, + }, + StepDeleted: { + id: 19, + text: 'STEP DELETED', + color: 'White', + clearable: true, + }, }; diff --git a/src/shared/src/NavigationDisplay.ts b/src/shared/src/NavigationDisplay.ts index c4a5948653c..c67922e686e 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 cb2389f3d45..9a98af235b2 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 Celsius = number; type InchesOfMercury = number; type Millibar = number;