diff --git a/fbw-a32nx/src/base/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_FlightPlanPage.js b/fbw-a32nx/src/base/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_FlightPlanPage.js index 744ec54ac5d..ffb50bfe31d 100644 --- a/fbw-a32nx/src/base/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_FlightPlanPage.js +++ b/fbw-a32nx/src/base/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_FlightPlanPage.js @@ -146,6 +146,10 @@ class CDUFlightPlanPage { waypointsAndMarkers.push({ wp, fpIndex: i, inAlternate: false, inMissedApproach, distanceFromLastLine, isActive: isActiveLeg && pseudoWaypointsOnLeg.length === 0 }); + if (wp.calculated && wp.calculated.endsInTooSteepPath) { + waypointsAndMarkers.push({ marker: Markers.TOO_STEEP_PATH, fpIndex: i, inAlternate: false, inMissedApproach }); + } + if (i === targetPlan.destinationLegIndex) { destinationAirportOffset = Math.max(waypointsAndMarkers.length - 4, 0); } @@ -579,7 +583,12 @@ class CDUFlightPlanPage { scrollWindow[rowI] = waypointsAndMarkers[winI]; addLskAt(rowI, 0, (value, scratchpadCallback) => { if (value === FMCMainDisplay.clrValue) { - CDUFlightPlanPage.clearElement(mcdu, fpIndex, offset, forPlan, inAlternate, scratchpadCallback); + if (marker.marker === Markers.FPLN_DISCONTINUITY) { + CDUFlightPlanPage.clearElement(mcdu, fpIndex, offset, forPlan, inAlternate, scratchpadCallback); + } else { + mcdu.setScratchpadMessage(NXSystemMessages.notAllowed); + scratchpadCallback(); + } return; } else if (value === "") { return; diff --git a/fbw-a32nx/src/base/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_VerticalRevisionPage.js b/fbw-a32nx/src/base/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_VerticalRevisionPage.js index 9c0ea557b9a..014361f72fc 100644 --- a/fbw-a32nx/src/base/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_VerticalRevisionPage.js +++ b/fbw-a32nx/src/base/flybywire-aircraft-a320-neo/html_ui/Pages/VCockpit/Instruments/Airliners/FlyByWire_A320_Neo/CDU/A320_Neo_CDU_VerticalRevisionPage.js @@ -9,6 +9,8 @@ const WaypointConstraintType = Object.freeze({ DES: 2, }); +const TOO_STEEP_PATH_BEYOND_MESSAGE = "{amber}TOO STEEP PATH BEYOND{end}"; + class CDUVerticalRevisionPage { /** * @param mcdu @@ -142,10 +144,12 @@ class CDUVerticalRevisionPage { } } + const tooSteepPathInfo = waypoint.calculated.endsInTooSteepPath ? TOO_STEEP_PATH_BEYOND_MESSAGE : ""; + mcdu.setTemplate([ ["VERT REV {small}AT{end}{green} " + waypointIdent + "{end}"], [], - [""], + ["", "", tooSteepPathInfo], [speedLimitTitle, ""], [speedLimitCell, "RTA>[color]inop"], [l3Title, r3Title], diff --git a/fbw-a32nx/src/systems/fmgc/src/components/fms-messages/FmsMessages.ts b/fbw-a32nx/src/systems/fmgc/src/components/fms-messages/FmsMessages.ts index 4c8941af198..6ce538fa2ff 100644 --- a/fbw-a32nx/src/systems/fmgc/src/components/fms-messages/FmsMessages.ts +++ b/fbw-a32nx/src/systems/fmgc/src/components/fms-messages/FmsMessages.ts @@ -22,6 +22,7 @@ import { FmgcComponent } from '../FmgcComponent'; import { GpsPrimary } from './GpsPrimary'; import { GpsPrimaryLost } from './GpsPrimaryLost'; import { MapPartlyDisplayedLeft, MapPartlyDisplayedRight } from './MapPartlyDisplayed'; +import { TooSteepPathAhead } from '@fmgc/components/fms-messages/TooSteepPathAhead'; /** * This class manages Type II messages sent from the FMGC. @@ -63,6 +64,7 @@ export class FmsMessages implements FmgcComponent { new TdReached(), new StepAhead(), new StepDeleted(), + new TooSteepPathAhead(), ]; init(baseInstrument: BaseInstrument, flightPlanService: FlightPlanService): void { diff --git a/fbw-a32nx/src/systems/fmgc/src/components/fms-messages/TooSteepPathAhead.ts b/fbw-a32nx/src/systems/fmgc/src/components/fms-messages/TooSteepPathAhead.ts new file mode 100644 index 00000000000..86d68c161ad --- /dev/null +++ b/fbw-a32nx/src/systems/fmgc/src/components/fms-messages/TooSteepPathAhead.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2021-2023 FlyByWire Simulations +// +// SPDX-License-Identifier: GPL-3.0 + +import { GuidanceController } from '@fmgc/guidance/GuidanceController'; +import { FMMessageTypes } from '@flybywiresim/fbw-sdk'; + +import { FMMessageSelector, FMMessageUpdate } from './FmsMessages'; + +export class TooSteepPathAhead implements FMMessageSelector { + message = FMMessageTypes.TooSteepPathAhead; + + private guidanceController: GuidanceController; + + private lastState = false; + + init(baseInstrument: BaseInstrument): void { + this.guidanceController = baseInstrument.guidanceController; + } + + process(_: number): FMMessageUpdate { + const newState = this.guidanceController.vnavDriver.shouldShowTooSteepPathAhead(); + + if (newState !== this.lastState) { + this.lastState = newState; + + return newState ? FMMessageUpdate.SEND : FMMessageUpdate.RECALL; + } + + return FMMessageUpdate.NO_ACTION; + } +} diff --git a/fbw-a32nx/src/systems/fmgc/src/flightplanning/legs/FlightPlanLeg.ts b/fbw-a32nx/src/systems/fmgc/src/flightplanning/legs/FlightPlanLeg.ts index a6e46ed99aa..e8246d42810 100644 --- a/fbw-a32nx/src/systems/fmgc/src/flightplanning/legs/FlightPlanLeg.ts +++ b/fbw-a32nx/src/systems/fmgc/src/flightplanning/legs/FlightPlanLeg.ts @@ -63,6 +63,8 @@ export interface LegCalculations { cumulativeDistanceWithTransitions: number; /** The cumulative distance in nautical miles from this point to the missed approach point, with leg transition turns taken into account. */ cumulativeDistanceToEndWithTransitions: number; + /** Whether the leg terminates in a vertical discontinuity */ + endsInTooSteepPath: boolean; } /** diff --git a/fbw-a32nx/src/systems/fmgc/src/flightplanning/plans/FlightPlan.ts b/fbw-a32nx/src/systems/fmgc/src/flightplanning/plans/FlightPlan.ts index 2bfe23e6fc8..75458a34eb9 100644 --- a/fbw-a32nx/src/systems/fmgc/src/flightplanning/plans/FlightPlan.ts +++ b/fbw-a32nx/src/systems/fmgc/src/flightplanning/plans/FlightPlan.ts @@ -626,4 +626,23 @@ export class FlightPlan

150 && isCruisePhase) || isDesOrApprPhase; + + if (!isManagedLateralMode || !isCloseToDestination) { + return false; + } + + return this.flightPlanService.active?.hasTooSteepPathAhead(); + } } class FcuModeObserver { @@ -754,7 +769,9 @@ class FcuModeObserver { LateralMode.NAV, LateralMode.LOC_CPT, LateralMode.LOC_TRACK, + LateralMode.LAND, LateralMode.RWY, + LateralMode.GA_TRACK, ]; private VERT_CLIMB_MODES: VerticalMode[] = [ diff --git a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/VnavDriver.ts b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/VnavDriver.ts index 52d93f416a7..282b6755022 100644 --- a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/VnavDriver.ts +++ b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/VnavDriver.ts @@ -32,6 +32,7 @@ import { } from './profile/NavGeometryProfile'; import { VMLeg } from '@fmgc/guidance/lnav/legs/VM'; import { FMLeg } from '@fmgc/guidance/lnav/legs/FM'; +import { MathUtils } from '@flybywiresim/fbw-sdk'; export class VnavDriver implements GuidanceComponent { version: number = 0; @@ -288,11 +289,10 @@ export class VnavDriver implements GuidanceComponent { const isPastCstrDeceleration = checkpoint.reason === VerticalCheckpointReason.StartDecelerationToConstraint && - currentDistanceFromStart - checkpoint.distanceFromStart > -1e-4; + MathUtils.isCloseToGreaterThan(currentDistanceFromStart, checkpoint.distanceFromStart); const isPastLimitDeceleration = checkpoint.reason === VerticalCheckpointReason.StartDecelerationToLimit && - currentAltitude - checkpoint.altitude < 1e-4; - + MathUtils.isCloseToLessThan(currentAltitude, checkpoint.altitude); if ( isSpeedChangePoint(checkpoint) && checkpoint.targetSpeed >= decelPointSpeed && @@ -630,6 +630,10 @@ export class VnavDriver implements GuidanceComponent { ? completeLegAlongTrackPathDtg + referenceLeg.calculated.cumulativeDistanceToEndWithTransitions : undefined; } + + shouldShowTooSteepPathAhead(): boolean { + return this.profileManager.shouldShowTooSteepPathAhead(); + } } /// To check whether the value changed from old to new, but not if both values are NaN. (NaN !== NaN in JS) diff --git a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/climb/ClimbStrategy.ts b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/climb/ClimbStrategy.ts index a1477dcde74..b1808d573d6 100644 --- a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/climb/ClimbStrategy.ts +++ b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/climb/ClimbStrategy.ts @@ -3,9 +3,12 @@ // SPDX-License-Identifier: GPL-3.0 import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters'; -import { DEFAULT_AIRCRAFT_CONTROL_SURFACE_CONFIG, DescentStrategy } from '@fmgc/guidance/vnav/descent/DescentStrategy'; +import { DescentStrategy } from '@fmgc/guidance/vnav/descent/DescentStrategy'; import { WindComponent } from '@fmgc/guidance/vnav/wind'; -import { AircraftConfiguration as AircraftCtlSurfcConfiguration } from '@fmgc/guidance/vnav/descent/ApproachPathBuilder'; +import { + AircraftConfiguration as AircraftCtlSurfcConfiguration, + DEFAULT_AIRCRAFT_CONTROL_SURFACE_CONFIG, +} from '@fmgc/guidance/vnav/descent/ApproachPathBuilder'; import { MathUtils } from '@flybywiresim/fbw-sdk'; import { UnitType } from '@microsoft/msfs-sdk'; import { AircraftConfig, EngineModelParameters } from '@fmgc/flightplanning/AircraftConfigTypes'; @@ -341,7 +344,7 @@ export class ClimbThrustClimbStrategy implements ClimbStrategy { tropoPause, config.speedbrakesExtended, config.flapConfig, - config.speedbrakesExtended, + config.gearExtended, perfFactor, ); } diff --git a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/ApproachPathBuilder.ts b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/ApproachPathBuilder.ts index b07cf5dfcef..9db6351749b 100644 --- a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/ApproachPathBuilder.ts +++ b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/ApproachPathBuilder.ts @@ -100,13 +100,43 @@ export interface AircraftConfiguration { 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 const DEFAULT_AIRCRAFT_CONTROL_SURFACE_CONFIG: Readonly = { + flapConfig: FlapConf.CLEAN, + speedbrakesExtended: false, + gearExtended: false, +}; + +/** + * Use this class to generate `AircraftConfiguration` objects. This reuses the same object to avoid unnecessary allocations. + * If multiple configurations are generated from the same register, they will be the same object. Use multiple registers + * if you need different configurations at the same time. + * + * @example + * const parameters = new VerticalProfileComputationParametersObserver().get(); + * const register = new AircraftConfigurationRegister(); + * + * // Appropriate configuration to fly at 250 kts + * const config = register.setFromSpeed(250, parameters); + * + * // Appropriate configuration to fly at 200 kts + * const config2 = register.setFromSpeed(200, parameters); + * + * // These configurations are now the *same* + * assert(config === config2); + */ +export class AircraftConfigurationRegister { + private readonly config: AircraftConfiguration = { ...DEFAULT_AIRCRAFT_CONTROL_SURFACE_CONFIG }; + + setFromSpeed(speed: Knots, parameters: VerticalProfileComputationParameters, useSpeedbrakes = false) { + this.config.flapConfig = FlapConfigurationProfile.getBySpeed(speed, parameters); + this.config.speedbrakesExtended = useSpeedbrakes; + this.config.gearExtended = speed < parameters.flapRetractionSpeed; + + return this.config; + } + + get(): Readonly { + return this.config; } } @@ -115,6 +145,10 @@ export class ApproachPathBuilder { private fpaStrategy: FlightPathAngleStrategy; + private readonly configuration = new AircraftConfigurationRegister(); + + private static readonly DISTANCE_EPSILON = 1e-4; + constructor( private observer: VerticalProfileComputationParametersObserver, atmosphericConditions: AtmosphericConditions, @@ -161,7 +195,7 @@ export class ApproachPathBuilder { managedDescentSpeedMach, estimatedFuelOnBoardAtDestination, windProfile.getHeadwindComponent(profile.getDistanceFromStart(0), finalAltitude), - AircraftConfigurationProfile.getBySpeed(approachSpeed, this.observer.get()), + this.configuration.setFromSpeed(approachSpeed, this.observer.get()), ); sequence.addCheckpointFromStep(finalApproachStep, VerticalCheckpointReason.AtmosphericConditions); @@ -195,7 +229,7 @@ export class ApproachPathBuilder { } const speedTarget = speedProfile.getTarget( - sequence.lastCheckpoint.distanceFromStart - 1e-4, + sequence.lastCheckpoint.distanceFromStart - ApproachPathBuilder.DISTANCE_EPSILON, sequence.lastCheckpoint.altitude, ManagedSpeedType.Descent, ); @@ -255,7 +289,10 @@ export class ApproachPathBuilder { let secondDecelerationSequence: TemporaryCheckpointSequence = null; // `decelerationSegmentDistance` should be positive - const tryDecelDistance = (decelerationSegmentDistance: NauticalMiles): NauticalMiles => { + const tryDecelDistance = ( + decelerationSegmentDistance: NauticalMiles, + useSpeedbrakes: boolean = false, + ): NauticalMiles => { const currentDecelerationAttempt = new TemporaryCheckpointSequence(sequence.lastCheckpoint); decelerationSequence = this.buildDecelerationPath( @@ -263,6 +300,7 @@ export class ApproachPathBuilder { speedProfile, windProfile, distanceFromStart - decelerationSegmentDistance, + useSpeedbrakes, ); currentDecelerationAttempt.push(...decelerationSequence.get()); @@ -273,7 +311,7 @@ export class ApproachPathBuilder { managedDescentSpeedMach, decelerationSequence.lastCheckpoint.remainingFuelOnBoard, windProfile.getHeadwindComponent(distanceFromStart - decelerationSegmentDistance, minimumAltitude), - AircraftConfigurationProfile.getBySpeed(decelerationSequence.lastCheckpoint.speed, this.observer.get()), + this.configuration.setFromSpeed(decelerationSequence.lastCheckpoint.speed, this.observer.get(), useSpeedbrakes), ); currentDecelerationAttempt.addCheckpointFromStep(descentSegment, VerticalCheckpointReason.AltitudeConstraint); @@ -292,6 +330,7 @@ export class ApproachPathBuilder { speedProfile, windProfile, constraint.distanceFromStart, + useSpeedbrakes, ); return distanceTraveled - desiredDistanceToCover; @@ -305,6 +344,43 @@ export class ApproachPathBuilder { ); tryDecelDistance(solution); + // Total distance is deceleration distance (`solution`) + descent distance (`-descentSegment.distanceTraveled`) + const distanceTraveled = solution + -descentSegment.distanceTraveled; + const finalError = distanceTraveled - desiredDistanceToCover; + + // Even without decelerating and only descending, we cannot make the constraint + if (MathUtils.isCloseToNegative(solution) && !MathUtils.isCloseToNegative(finalError)) { + // Try with speedbrakes + const solutionWithSpeedbrakes = BisectionMethod.findZero( + (distance) => tryDecelDistance(distance, true), + [0, desiredDistanceToCover], + [-0.1, 0.1], + NonTerminationStrategy.NegativeErrorResult, + ); + tryDecelDistance(solutionWithSpeedbrakes, true); + + const distanceTraveledWithSpeedbrakes = solutionWithSpeedbrakes + -descentSegment.distanceTraveled; + const finalErrorWithSpeedbrakes = distanceTraveledWithSpeedbrakes - desiredDistanceToCover; + + if ( + MathUtils.isCloseToNegative(solutionWithSpeedbrakes) && + !MathUtils.isCloseToNegative(finalErrorWithSpeedbrakes) + ) { + // Insert TOO STEEP PATH + const scaling = desiredDistanceToCover / -descentSegment.distanceTraveled; + this.scaleStepBasedOnLastCheckpoint(sequence.lastCheckpoint, descentSegment, scaling); + sequence.addCheckpointFromStep(descentSegment, VerticalCheckpointReason.GeometricPathTooSteep); + sequence.copyLastCheckpoint({ + reason: VerticalCheckpointReason.AltitudeConstraint, + altitude: minimumAltitude, + }); + + constraint.leg.calculated.endsInTooSteepPath = true; + + return; + } + } + sequence.push(...decelerationSequence.get()); const speedTarget = speedProfile.getTarget( @@ -337,6 +413,7 @@ export class ApproachPathBuilder { * @param speedProfile * @param windProfile * @param targetDistanceFromStart + * @param useSpeedbrakes * @returns */ private buildDecelerationPath( @@ -344,6 +421,7 @@ export class ApproachPathBuilder { speedProfile: SpeedProfile, windProfile: HeadwindProfile, targetDistanceFromStart: NauticalMiles, + useSpeedbrakes = false, ): TemporaryCheckpointSequence { const decelerationSequence = new TemporaryCheckpointSequence(lastCheckpoint); @@ -353,12 +431,14 @@ export class ApproachPathBuilder { const isDoneDeclerating = () => decelerationSequence.lastCheckpoint.reason === VerticalCheckpointReason.Decel || - decelerationSequence.lastCheckpoint.distanceFromStart - targetDistanceFromStart <= 1e-4; // We really only want to prevent floating point errors here + MathUtils.isCloseToLessThan(decelerationSequence.lastCheckpoint.distanceFromStart, targetDistanceFromStart); // We really only want to prevent floating point errors here for (let i = 0; i < 10 && !isDoneDeclerating(); i++) { const { distanceFromStart, altitude, speed, remainingFuelOnBoard } = decelerationSequence.lastCheckpoint; - const speedConstraint = speedProfile.getMaxDescentSpeedConstraint(distanceFromStart - 1e-4); + const speedConstraint = speedProfile.getMaxDescentSpeedConstraint( + distanceFromStart - ApproachPathBuilder.DISTANCE_EPSILON, + ); const flapTargetSpeed = FlapConfigurationProfile.findNextExtensionSpeed(speed, parameters); // This is the managed descent speed, or the speed limit speed. @@ -389,7 +469,7 @@ export class ApproachPathBuilder { parameters.managedDescentSpeedMach, remainingFuelOnBoard, windProfile.getHeadwindComponent(distanceFromStart, altitude), - AircraftConfigurationProfile.getBySpeed(speed, parameters), + this.configuration.setFromSpeed(speed, parameters, useSpeedbrakes), ); if (decelerationStep.error !== undefined || decelerationStep.distanceTraveled > 0) { @@ -404,7 +484,7 @@ export class ApproachPathBuilder { lastAccelerationCheckpointIndex = decelerationSequence.length; - if (decelerationStep.distanceTraveled < 1e-4) { + if (MathUtils.isCloseToNegative(decelerationStep.distanceTraveled)) { // 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); @@ -430,7 +510,7 @@ export class ApproachPathBuilder { parameters.managedDescentSpeedMach, remainingFuelOnBoard - decelerationStep.fuelBurned, windProfile.getHeadwindComponent(distanceFromStart, altitude), - AircraftConfigurationProfile.getBySpeed(speedConstraint.maxSpeed, parameters), + this.configuration.setFromSpeed(speedConstraint.maxSpeed, parameters, useSpeedbrakes), ); if (constantStep.distanceTraveled > 0) { @@ -447,7 +527,7 @@ export class ApproachPathBuilder { // We don't care about any speed constraint limitations here, because that's what the if block above is for. const targetSpeed = Math.min(flapTargetSpeed, limitingSpeed); - const config = AircraftConfigurationProfile.getBySpeed(speed, parameters); + const config = this.configuration.setFromSpeed(speed, parameters, useSpeedbrakes); const decelerationStep = this.fpaStrategy.predictToSpeed( altitude, @@ -488,7 +568,11 @@ export class ApproachPathBuilder { } if (!isDoneDeclerating()) { - const config = AircraftConfigurationProfile.getBySpeed(decelerationSequence.lastCheckpoint.speed, parameters); + const config = this.configuration.setFromSpeed( + decelerationSequence.lastCheckpoint.speed, + parameters, + useSpeedbrakes, + ); // Fly constant speed instead const constantSpeedStep = this.fpaStrategy.predictToDistance( diff --git a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/DescentPathBuilder.ts b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/DescentPathBuilder.ts index 2dd27106046..206eea56754 100644 --- a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/DescentPathBuilder.ts +++ b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/DescentPathBuilder.ts @@ -2,45 +2,36 @@ // // SPDX-License-Identifier: GPL-3.0 -import { - DescentAltitudeConstraint, - MaxSpeedConstraint, - VerticalCheckpoint, - VerticalCheckpointReason, -} from '@fmgc/guidance/vnav/profile/NavGeometryProfile'; +import { 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 { 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 { AltitudeDescriptor, MathUtils } from '@flybywiresim/fbw-sdk'; -import { SpeedLimit } from '@fmgc/guidance/vnav/SpeedLimit'; import { ConstraintUtils, AltitudeConstraint } from '@fmgc/flightplanning/data/constraint'; import { AircraftConfig } from '@fmgc/flightplanning/AircraftConfigTypes'; +import { IdlePathBuilder } from '@fmgc/guidance/vnav/descent/IdlePathBuilder'; +import { GeometricPathPlanner, PlannedGeometricSegment } from '@fmgc/guidance/vnav/descent/GeometricPathPlanner'; export class DescentPathBuilder { private geometricPathBuilder: GeometricPathBuilder; - private idleDescentStrategy: DescentStrategy; + private idlePathBuilder: IdlePathBuilder; constructor( - private computationParametersObserver: VerticalProfileComputationParametersObserver, - private atmosphericConditions: AtmosphericConditions, + computationParametersObserver: VerticalProfileComputationParametersObserver, + atmosphericConditions: AtmosphericConditions, private readonly acConfig: AircraftConfig, ) { - this.geometricPathBuilder = new GeometricPathBuilder( - computationParametersObserver, - atmosphericConditions, - this.acConfig, - ); + this.idlePathBuilder = new IdlePathBuilder(computationParametersObserver, atmosphericConditions, this.acConfig); - this.idleDescentStrategy = new IdleDescentStrategy( + this.geometricPathBuilder = new GeometricPathBuilder( computationParametersObserver, atmosphericConditions, + this, this.acConfig, ); } @@ -51,6 +42,7 @@ export class DescentPathBuilder { speedProfile: SpeedProfile, windProfile: HeadwindProfile, cruiseAltitude: Feet, + toDistance: NauticalMiles = -Infinity, ) { const TOL = 10; const decelPoint: VerticalCheckpoint = { ...sequence.lastCheckpoint }; @@ -58,12 +50,22 @@ export class DescentPathBuilder { const geometricSequence = new TemporaryCheckpointSequence(decelPoint); const idleSequence = new TemporaryCheckpointSequence(decelPoint); - this.buildIdlePath(idleSequence, profile, speedProfile, windProfile, cruiseAltitude); + this.idlePathBuilder.buildIdlePath( + idleSequence, + profile.descentSpeedConstraints, + speedProfile, + windProfile, + cruiseAltitude, + toDistance, + ); for (let i = profile.descentAltitudeConstraints.length - 1; i >= 0; i -= 1) { const constraintPoint = profile.descentAltitudeConstraints[i]; - if (constraintPoint.distanceFromStart >= decelPoint.distanceFromStart) { + if ( + constraintPoint.distanceFromStart >= decelPoint.distanceFromStart || + constraintPoint.distanceFromStart <= toDistance + ) { // If we've found a constraint that's beyond the decel point, we can ignore it. continue; } else if (!this.isConstraintBelowCruisingAltitude(constraintPoint.constraint, cruiseAltitude)) { @@ -81,6 +83,7 @@ export class DescentPathBuilder { const geometricPathPoint = { distanceFromStart: constraintPoint.distanceFromStart, altitude: altitudeToContinueFrom, + leg: constraintPoint.leg, }; // Plan geometric path between decel point and geometric path point (point between geometric and idle path) @@ -98,12 +101,20 @@ export class DescentPathBuilder { this.geometricPathBuilder.executeGeometricSegments( geometricSequence, geometricSegments, - profile.descentSpeedConstraints.slice(), + profile, + speedProfile, windProfile, ); idleSequence.reset(geometricSequence.lastCheckpoint); - this.buildIdlePath(idleSequence, profile, speedProfile, windProfile, cruiseAltitude); + this.idlePathBuilder.buildIdlePath( + idleSequence, + profile.descentSpeedConstraints, + speedProfile, + windProfile, + cruiseAltitude, + toDistance, + ); } } @@ -117,233 +128,16 @@ export class DescentPathBuilder { 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, descentSpeedLimit } = this.computationParametersObserver.get(); - const speedConstraintsAhead = this.speedConstraintGenerator(profile.descentSpeedConstraints, sequence); - - // We try to figure out what speed we might be decelerating for - let previousCasTarget = - this.tryGetAnticipatedTarget( - sequence, - profile.descentSpeedConstraints, - speedProfile.shouldTakeDescentSpeedLimitIntoAccount() ? descentSpeedLimit : null, - ) ?? - speedProfile.getTarget( - sequence.lastCheckpoint.distanceFromStart, - sequence.lastCheckpoint.altitude, - ManagedSpeedType.Descent, - ); - let wasPreviouslyUnderSpeedLimitAltitude = - speedProfile.shouldTakeDescentSpeedLimitIntoAccount() && - sequence.lastCheckpoint.altitude < descentSpeedLimit.underAltitude; - - for (let i = 0; i < 100 && topOfDescentAltitude - sequence.lastCheckpoint.altitude > 1; i++) { - const { distanceFromStart, altitude, speed, remainingFuelOnBoard } = sequence.lastCheckpoint; - const headwind = windProfile.getHeadwindComponent(distanceFromStart, altitude); - const isUnderSpeedLimitAltitude = - speedProfile.shouldTakeDescentSpeedLimitIntoAccount() && altitude < descentSpeedLimit.underAltitude; - - const casTarget = speedProfile.getTarget(distanceFromStart - 1e-4, altitude + 1e-4, ManagedSpeedType.Descent); - const currentSpeedTarget = Math.min( - casTarget, - this.atmosphericConditions.computeCasFromMach(managedDescentSpeedMach, altitude), - ); - const canAccelerate = currentSpeedTarget > speed; - - if (canAccelerate) { - // Build acceleration path - const speedStep = this.idleDescentStrategy.predictToSpeed( - altitude, - casTarget, - speed, - managedDescentSpeedMach, - remainingFuelOnBoard, - headwind, - ); - const scaling = Math.min(1, (topOfDescentAltitude - altitude) / (speedStep.finalAltitude - altitude)); - this.scaleStepBasedOnLastCheckpoint(sequence.lastCheckpoint, speedStep, scaling); - - const didCrossoverSpeedLimitAltitude = wasPreviouslyUnderSpeedLimitAltitude && !isUnderSpeedLimitAltitude; - const checkpointReason = didCrossoverSpeedLimitAltitude - ? VerticalCheckpointReason.StartDecelerationToLimit - : VerticalCheckpointReason.StartDecelerationToConstraint; - - sequence.addDecelerationCheckpointFromStep(speedStep, checkpointReason, previousCasTarget); - } else { - // Try alt path - let finalAltitude = Math.min(altitude + 1500, topOfDescentAltitude); - let reason = VerticalCheckpointReason.IdlePathAtmosphericConditions; - if (isUnderSpeedLimitAltitude && finalAltitude >= descentSpeedLimit.underAltitude) { - finalAltitude = descentSpeedLimit.underAltitude; - reason = VerticalCheckpointReason.CrossingDescentSpeedLimit; - } - - const altitudeStep = this.idleDescentStrategy.predictToAltitude( - altitude, - finalAltitude, - speed, - managedDescentSpeedMach, - remainingFuelOnBoard, - headwind, - ); - // Check if constraint violated - const nextSpeedConstraint = speedConstraintsAhead.next(); - if ( - !nextSpeedConstraint.done && - distanceFromStart + altitudeStep.distanceTraveled < nextSpeedConstraint.value.distanceFromStart - ) { - // Constraint violated - const distanceToConstraint = nextSpeedConstraint.value.distanceFromStart - distanceFromStart; - const distanceStep = this.idleDescentStrategy.predictToDistance( - altitude, - distanceToConstraint, - speed, - managedDescentSpeedMach, - remainingFuelOnBoard, - headwind, - ); - sequence.addCheckpointFromStep(distanceStep, VerticalCheckpointReason.SpeedConstraint); - } else { - sequence.addCheckpointFromStep(altitudeStep, reason); - } - } - - previousCasTarget = casTarget; - wasPreviouslyUnderSpeedLimitAltitude = isUnderSpeedLimitAltitude; - } - - if (sequence.lastCheckpoint.reason === VerticalCheckpointReason.IdlePathAtmosphericConditions) { - sequence.lastCheckpoint.reason = VerticalCheckpointReason.TopOfDescent; - } else { - sequence.copyLastCheckpoint({ reason: VerticalCheckpointReason.TopOfDescent }); - } - } - - // TODO: I really don't know if this function does what it's supposed to, so I hope I don't have to return to it. - // The problem it's trying to solve is this: After having built the geometric path to the first violating altitude constraint, we might not be at econ speed yet. - // So then we need to accelerate to it on the idle path. However, we want to figure out what the reason for this acceleration is, i.e whether it is due - // to the speed limit or a constraint. We need to know this to place the correct checkpoint reason. - // What the function does is to try and figure this out based on different criteria. - private tryGetAnticipatedTarget( - sequence: TemporaryCheckpointSequence, - speedConstraints: MaxSpeedConstraint[], - speedLimit?: SpeedLimit, - ): Knots | null { - const { - distanceFromStart: pposDistanceFromStart, - speed: currentSpeed, - altitude: currentAltitude, - } = sequence.lastCheckpoint; - - // Find next constraint - const nextSpeedConstraint = speedConstraints.find( - (c) => c.distanceFromStart >= pposDistanceFromStart && c.maxSpeed <= currentSpeed, - ); - - const isSpeedLimitValidCandidate = - speedLimit && currentAltitude > speedLimit.underAltitude && currentSpeed > speedLimit.speed; - - if (nextSpeedConstraint) { - // Try to figure out which speed is more important - if (isSpeedLimitValidCandidate) { - const altAtConstraint = sequence.interpolateAltitudeBackwards(nextSpeedConstraint.distanceFromStart); - - if (speedLimit.underAltitude > altAtConstraint) { - return speedLimit.speed; - } - } - - return nextSpeedConstraint.maxSpeed; - } - - // If we did not find a valid speed constraint candidate, see if the speed limit might be a candidate. If so, return it. - if (isSpeedLimitValidCandidate) { - return speedLimit.speed; - } - - return null; - } - - 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 { + private isConstraintBelowCruisingAltitude(constraint: AltitudeConstraint, finalCruiseAltitude: Feet): boolean { return ConstraintUtils.minimumAltitude(constraint) <= finalCruiseAltitude; } - - private *speedConstraintGenerator( - constraints: MaxSpeedConstraint[], - sequence: TemporaryCheckpointSequence, - ): Generator { - for (let i = constraints.length - 1; i >= 0; ) { - // Small tolerance here, so we don't get stuck on a constraint - if (constraints[i].distanceFromStart - sequence.lastCheckpoint.distanceFromStart > -1e-4) { - i--; - continue; - } - - yield constraints[i]; - } - } -} - -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] { +export 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.altitudeDescriptor) { case AltitudeDescriptor.AtAlt1: @@ -393,20 +187,3 @@ export const isAltitudeConstraintMet = (constraint: AltitudeConstraint, altitude 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/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/DescentStrategy.ts b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/DescentStrategy.ts index 59108192ff4..5a4182c5883 100644 --- a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/DescentStrategy.ts +++ b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/DescentStrategy.ts @@ -6,19 +6,16 @@ import { AircraftConfig } from '@fmgc/flightplanning/AircraftConfigTypes'; import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions'; import { FlightPathAngleStrategy, VerticalSpeedStrategy } from '@fmgc/guidance/vnav/climb/ClimbStrategy'; import { FlapConf } from '@fmgc/guidance/vnav/common'; -import { AircraftConfiguration as AircraftCtlSurfcConfiguration } from '@fmgc/guidance/vnav/descent/ApproachPathBuilder'; +import { + AircraftConfiguration as AircraftCtlSurfcConfiguration, + DEFAULT_AIRCRAFT_CONTROL_SURFACE_CONFIG, +} 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'; -export const DEFAULT_AIRCRAFT_CONTROL_SURFACE_CONFIG: AircraftCtlSurfcConfiguration = { - flapConfig: FlapConf.CLEAN, - speedbrakesExtended: false, - gearExtended: false, -}; - export interface DescentStrategy { /** * Computes predictions for a single segment using the atmospheric conditions in the middle. diff --git a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/GeometricPathBuilder.ts b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/GeometricPathBuilder.ts index b5cb4995994..0a9723bf7bc 100644 --- a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/GeometricPathBuilder.ts +++ b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/GeometricPathBuilder.ts @@ -5,7 +5,6 @@ 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, @@ -14,10 +13,13 @@ import { } 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 '@flybywiresim/fbw-sdk'; import { AircraftConfig } from '@fmgc/flightplanning/AircraftConfigTypes'; +import { PlannedGeometricSegment } from '@fmgc/guidance/vnav/descent/GeometricPathPlanner'; +import { SpeedProfile } from '@fmgc/guidance/vnav/climb/SpeedProfile'; +import { DescentPathBuilder } from '@fmgc/guidance/vnav/descent/DescentPathBuilder'; +import { BaseGeometryProfile } from '@fmgc/guidance/vnav/profile/BaseGeometryProfile'; export class GeometricPathBuilder { private flightPathAngleStrategy: FlightPathAngleStrategy; @@ -25,6 +27,7 @@ export class GeometricPathBuilder { constructor( private observer: VerticalProfileComputationParametersObserver, atmosphericConditions: AtmosphericConditions, + private readonly descentPathBuilder: DescentPathBuilder, private readonly acConfig: AircraftConfig, ) { this.flightPathAngleStrategy = new FlightPathAngleStrategy(observer, atmosphericConditions, -3.0, this.acConfig); @@ -33,14 +36,19 @@ export class GeometricPathBuilder { executeGeometricSegments( sequence: TemporaryCheckpointSequence, segments: PlannedGeometricSegment[], - speedConstraints: MaxSpeedConstraint[], + profile: BaseGeometryProfile, + speedProfile: SpeedProfile, windProfile: HeadwindProfile, ) { + const speedConstraints = profile.descentSpeedConstraints; + const accelerationTargets = this.buildAccelerationTargets(sequence.lastCheckpoint, segments, speedConstraints); const lastTarget: AccelerationTarget = null; for (const segment of segments) { const currentSegmentSequence = new TemporaryCheckpointSequence(sequence.lastCheckpoint); + this.flightPathAngleStrategy.flightPathAngle = + MathUtils.RADIANS_TO_DEGREES * Math.atan(segment.gradient / 6076.12); if ( !this.executeGeometricSegment( @@ -62,24 +70,39 @@ export class GeometricPathBuilder { lastTarget, ) ) { - // 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( + // If we cannot do meet the constraint with speedbrakes, build a new descent path to the constraint + this.descentPathBuilder.computeManagedDescentPath( currentSegmentSequence, - segment, - accelerationTargets, + profile, + speedProfile, windProfile, - true, - lastTarget, + Infinity, + segment.end.distanceFromStart, ); + // The path built above is only a "sub-path" of the full descent path, so we want to remove any checkpoints + // with significance to the full path + currentSegmentSequence.checkpoints.forEach((checkpoint) => { + if ( + checkpoint.reason === VerticalCheckpointReason.GeometricPathStart || + checkpoint.reason === VerticalCheckpointReason.GeometricPathEnd || + checkpoint.reason === VerticalCheckpointReason.IdlePathEnd || + checkpoint.reason === VerticalCheckpointReason.TopOfDescent + ) { + checkpoint.reason = VerticalCheckpointReason.AtmosphericConditions; + } + }); + sequence.push(...currentSegmentSequence.get()); + sequence.lastCheckpoint.reason = VerticalCheckpointReason.GeometricPathTooSteep; + sequence.copyLastCheckpoint({ - reason: VerticalCheckpointReason.GeometricPathTooSteep, + reason: VerticalCheckpointReason.AltitudeConstraint, altitude: segment.end.altitude, }); + segment.end.leg.calculated.endsInTooSteepPath = true; + continue; } } @@ -97,9 +120,8 @@ export class GeometricPathBuilder { lastTarget: AccelerationTarget, ) { const { managedDescentSpeedMach } = this.observer.get(); - this.flightPathAngleStrategy.flightPathAngle = MathUtils.RADIANS_TO_DEGREES * Math.atan(segment.gradient / 6076.12); - for (let i = 0; i < accelerationTargets.length && !segment.isTooSteep; i++) { + for (let i = 0; i < accelerationTargets.length; i++) { const accelerationTarget = accelerationTargets[i]; const maxDistance = segment.end.distanceFromStart - sequence.lastCheckpoint.distanceFromStart; @@ -123,22 +145,6 @@ export class GeometricPathBuilder { ); 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; } diff --git a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/GeometricPathPlanner.ts b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/GeometricPathPlanner.ts new file mode 100644 index 00000000000..513353ecb49 --- /dev/null +++ b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/GeometricPathPlanner.ts @@ -0,0 +1,67 @@ +import { FlightPlanLeg } from '@fmgc/flightplanning/legs/FlightPlanLeg'; +import { evaluateAltitudeConstraint } from '@fmgc/guidance/vnav/descent/DescentPathBuilder'; +import { DescentAltitudeConstraint } from '@fmgc/guidance/vnav/profile/NavGeometryProfile'; + +export class GeometricPathPlanner { + static planDescentSegments( + constraints: DescentAltitudeConstraint[], + start: GeometricPathPoint, + end: GeometricPathPoint & { leg: FlightPlanLeg }, + 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, + leg: constraintPoint.leg, + }; + + this.planDescentSegments(constraints, start, center, segments, tolerance); + this.planDescentSegments(constraints, center, end, segments, tolerance); + + return; + } + } + + segments.push({ end, gradient }); + } +} + +type GeometricPathPoint = { + distanceFromStart: NauticalMiles; + altitude: Feet; +}; + +export type PlannedGeometricSegment = { + gradient: number; + end: GeometricPathPoint & { leg: FlightPlanLeg }; + isTooSteep?: boolean; +}; + +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/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/IdlePathBuilder.ts b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/IdlePathBuilder.ts new file mode 100644 index 00000000000..eb9b08e803f --- /dev/null +++ b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/IdlePathBuilder.ts @@ -0,0 +1,259 @@ +import { MathUtils } from '@flybywiresim/fbw-sdk'; +import { AircraftConfig } from '@fmgc/flightplanning/AircraftConfigTypes'; +import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions'; +import { SpeedProfile, ManagedSpeedType } from '@fmgc/guidance/vnav/climb/SpeedProfile'; +import { DescentStrategy, IdleDescentStrategy } from '@fmgc/guidance/vnav/descent/DescentStrategy'; +import { StepResults } from '@fmgc/guidance/vnav/Predictions'; +import { + VerticalCheckpointReason, + MaxSpeedConstraint, + VerticalCheckpoint, +} from '@fmgc/guidance/vnav/profile/NavGeometryProfile'; +import { TemporaryCheckpointSequence } from '@fmgc/guidance/vnav/profile/TemporaryCheckpointSequence'; +import { SpeedLimit } from '@fmgc/guidance/vnav/SpeedLimit'; +import { VerticalProfileComputationParametersObserver } from '@fmgc/guidance/vnav/VerticalProfileComputationParameters'; +import { HeadwindProfile } from '@fmgc/guidance/vnav/wind/HeadwindProfile'; + +export class IdlePathBuilder { + private readonly idleDescentStrategy: DescentStrategy; + + /** + * Sometimes we want to check if two distances are close enough to each other to be considered equal, or sometimes we + * want to use a distance "just before" or "just after" another distance. + */ + private static readonly DISTANCE_EPSILON = 1e-4; + + constructor( + private computationParametersObserver: VerticalProfileComputationParametersObserver, + private atmosphericConditions: AtmosphericConditions, + private readonly acConfig: AircraftConfig, + ) { + this.idleDescentStrategy = new IdleDescentStrategy( + computationParametersObserver, + atmosphericConditions, + this.acConfig, + ); + } + + buildIdlePath( + sequence: TemporaryCheckpointSequence, + descentSpeedConstraints: MaxSpeedConstraint[], + speedProfile: SpeedProfile, + windProfile: HeadwindProfile, + topOfDescentAltitude: Feet, + toDistance: NauticalMiles = -Infinity, + ): void { + // Assume the last checkpoint is the start of the geometric path + sequence.copyLastCheckpoint({ reason: VerticalCheckpointReason.IdlePathEnd }); + + this.buildIdleSequence( + sequence, + descentSpeedConstraints, + speedProfile, + windProfile, + topOfDescentAltitude, + toDistance, + ); + + if (sequence.lastCheckpoint.reason === VerticalCheckpointReason.IdlePathAtmosphericConditions) { + sequence.lastCheckpoint.reason = VerticalCheckpointReason.TopOfDescent; + } else { + sequence.copyLastCheckpoint({ reason: VerticalCheckpointReason.TopOfDescent }); + } + } + + private buildIdleSequence( + sequence: TemporaryCheckpointSequence, + descentSpeedConstraints: MaxSpeedConstraint[], + speedProfile: SpeedProfile, + windProfile: HeadwindProfile, + topOfDescentAltitude: Feet, + toDistance: NauticalMiles = -Infinity, + ) { + const { managedDescentSpeedMach, descentSpeedLimit } = this.computationParametersObserver.get(); + const speedConstraintsAhead = this.speedConstraintGenerator(descentSpeedConstraints, sequence); + + // We try to figure out what speed we might be decelerating for + let previousCasTarget = + this.tryGetAnticipatedTarget( + sequence, + descentSpeedConstraints, + speedProfile.shouldTakeDescentSpeedLimitIntoAccount() ? descentSpeedLimit : null, + ) ?? + speedProfile.getTarget( + sequence.lastCheckpoint.distanceFromStart, + sequence.lastCheckpoint.altitude, + ManagedSpeedType.Descent, + ); + let wasPreviouslyUnderSpeedLimitAltitude = + speedProfile.shouldTakeDescentSpeedLimitIntoAccount() && + sequence.lastCheckpoint.altitude < descentSpeedLimit.underAltitude; + + for ( + let i = 0; + i < 100 && + !MathUtils.isCloseToLessThan(sequence.lastCheckpoint.distanceFromStart, toDistance) && + !MathUtils.isCloseToLessThan(topOfDescentAltitude, sequence.lastCheckpoint.altitude, 1); + i++ + ) { + const { distanceFromStart, altitude, speed, remainingFuelOnBoard } = sequence.lastCheckpoint; + const headwind = windProfile.getHeadwindComponent(distanceFromStart, altitude); + const isUnderSpeedLimitAltitude = + speedProfile.shouldTakeDescentSpeedLimitIntoAccount() && altitude < descentSpeedLimit.underAltitude; + + const casTarget = speedProfile.getTarget( + distanceFromStart - IdlePathBuilder.DISTANCE_EPSILON, + altitude + IdlePathBuilder.DISTANCE_EPSILON, + ManagedSpeedType.Descent, + ); + const currentSpeedTarget = Math.min( + casTarget, + this.atmosphericConditions.computeCasFromMach(managedDescentSpeedMach, altitude), + ); + const canAccelerate = currentSpeedTarget > speed; + + if (canAccelerate) { + // Build acceleration path + const speedStep = this.idleDescentStrategy.predictToSpeed( + altitude, + casTarget, + speed, + managedDescentSpeedMach, + remainingFuelOnBoard, + headwind, + ); + const scaling = Math.min( + 1, + (topOfDescentAltitude - altitude) / (speedStep.finalAltitude - altitude), + (toDistance - sequence.lastCheckpoint.distanceFromStart) / speedStep.distanceTraveled, + ); + this.scaleStepBasedOnLastCheckpoint(sequence.lastCheckpoint, speedStep, scaling); + + const didCrossoverSpeedLimitAltitude = wasPreviouslyUnderSpeedLimitAltitude && !isUnderSpeedLimitAltitude; + const checkpointReason = didCrossoverSpeedLimitAltitude + ? VerticalCheckpointReason.StartDecelerationToLimit + : VerticalCheckpointReason.StartDecelerationToConstraint; + + sequence.addDecelerationCheckpointFromStep(speedStep, checkpointReason, previousCasTarget); + } else { + // Try alt path + let finalAltitude = Math.min(altitude + 1500, topOfDescentAltitude); + + if (isUnderSpeedLimitAltitude) { + finalAltitude = Math.min(finalAltitude, descentSpeedLimit.underAltitude); + } + + const altitudeStep = this.idleDescentStrategy.predictToAltitude( + altitude, + finalAltitude, + speed, + managedDescentSpeedMach, + remainingFuelOnBoard, + headwind, + ); + const scaling = Math.min( + 1, + (toDistance - sequence.lastCheckpoint.distanceFromStart) / altitudeStep.distanceTraveled, + ); + this.scaleStepBasedOnLastCheckpoint(sequence.lastCheckpoint, altitudeStep, scaling); + + const reason = + isUnderSpeedLimitAltitude && altitudeStep.finalAltitude >= descentSpeedLimit.underAltitude + ? VerticalCheckpointReason.CrossingDescentSpeedLimit + : VerticalCheckpointReason.IdlePathAtmosphericConditions; + + // Check if constraint violated + const nextSpeedConstraint = speedConstraintsAhead.next(); + if ( + !nextSpeedConstraint.done && + distanceFromStart + altitudeStep.distanceTraveled < nextSpeedConstraint.value.distanceFromStart + ) { + // Constraint violated + const distanceToConstraint = nextSpeedConstraint.value.distanceFromStart - distanceFromStart; + const distanceStep = this.idleDescentStrategy.predictToDistance( + altitude, + distanceToConstraint, + speed, + managedDescentSpeedMach, + remainingFuelOnBoard, + headwind, + ); + sequence.addCheckpointFromStep(distanceStep, VerticalCheckpointReason.SpeedConstraint); + } else { + sequence.addCheckpointFromStep(altitudeStep, reason); + } + } + + previousCasTarget = casTarget; + wasPreviouslyUnderSpeedLimitAltitude = isUnderSpeedLimitAltitude; + } + } + + // TODO: I really don't know if this function does what it's supposed to, so I hope I don't have to return to it. + // The problem it's trying to solve is this: After having built the geometric path to the first violating altitude constraint, we might not be at econ speed yet. + // So then we need to accelerate to it on the idle path. However, we want to figure out what the reason for this acceleration is, i.e whether it is due + // to the speed limit or a constraint. We need to know this to place the correct checkpoint reason. + // What the function does is to try and figure this out based on different criteria. + private tryGetAnticipatedTarget( + sequence: TemporaryCheckpointSequence, + speedConstraints: MaxSpeedConstraint[], + speedLimit?: SpeedLimit, + ): Knots | null { + const { + distanceFromStart: pposDistanceFromStart, + speed: currentSpeed, + altitude: currentAltitude, + } = sequence.lastCheckpoint; + + // Find next constraint + const nextSpeedConstraint = speedConstraints.find( + (c) => c.distanceFromStart >= pposDistanceFromStart && c.maxSpeed <= currentSpeed, + ); + + const isSpeedLimitValidCandidate = + speedLimit && currentAltitude > speedLimit.underAltitude && currentSpeed > speedLimit.speed; + + if (nextSpeedConstraint) { + // Try to figure out which speed is more important + if (isSpeedLimitValidCandidate) { + const altAtConstraint = sequence.interpolateAltitudeBackwards(nextSpeedConstraint.distanceFromStart); + + if (speedLimit.underAltitude > altAtConstraint) { + return speedLimit.speed; + } + } + + return nextSpeedConstraint.maxSpeed; + } + + // If we did not find a valid speed constraint candidate, see if the speed limit might be a candidate. If so, return it. + if (isSpeedLimitValidCandidate) { + return speedLimit.speed; + } + + return null; + } + + 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; + } + + private *speedConstraintGenerator( + constraints: MaxSpeedConstraint[], + sequence: TemporaryCheckpointSequence, + ): Generator { + for (let i = constraints.length - 1; i >= 0; ) { + // Small tolerance here, so we don't get stuck on a constraint + if (MathUtils.isCloseToGreaterThan(constraints[i].distanceFromStart, sequence.lastCheckpoint.distanceFromStart)) { + i--; + continue; + } + + yield constraints[i]; + } + } +} diff --git a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/TacticalDescentPathBuilder.ts b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/TacticalDescentPathBuilder.ts index 1fa12ec9e44..5aeb6daa143 100644 --- a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/TacticalDescentPathBuilder.ts +++ b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/descent/TacticalDescentPathBuilder.ts @@ -7,7 +7,7 @@ import { AircraftConfig } from '@fmgc/flightplanning/AircraftConfigTypes'; import { AtmosphericConditions } from '@fmgc/guidance/vnav/AtmosphericConditions'; import { VerticalSpeedStrategy } from '@fmgc/guidance/vnav/climb/ClimbStrategy'; import { SpeedProfile } from '@fmgc/guidance/vnav/climb/SpeedProfile'; -import { AircraftConfiguration, AircraftConfigurationProfile } from '@fmgc/guidance/vnav/descent/ApproachPathBuilder'; +import { AircraftConfiguration, AircraftConfigurationRegister } from '@fmgc/guidance/vnav/descent/ApproachPathBuilder'; import { DescentStrategy } from '@fmgc/guidance/vnav/descent/DescentStrategy'; import { StepResults } from '@fmgc/guidance/vnav/Predictions'; import { BaseGeometryProfile } from '@fmgc/guidance/vnav/profile/BaseGeometryProfile'; @@ -605,6 +605,8 @@ class PhaseTable { phases: SubPhase[] = []; + private readonly configuration: AircraftConfigurationRegister = new AircraftConfigurationRegister(); + constructor( private readonly parameters: VerticalProfileComputationParameters, private readonly winds: HeadwindProfile, @@ -627,12 +629,10 @@ class PhaseTable { sequence.lastCheckpoint.distanceFromStart, sequence.lastCheckpoint.altitude, ); - const configuration = AircraftConfigurationProfile.getBySpeed(sequence.lastCheckpoint.speed, this.parameters); - const phaseResult = phase.execute(phase.shouldFlyAsLevelSegment ? levelFlightStrategy : descentStrategy)( sequence.lastCheckpoint, headwind, - configuration, + this.configuration.setFromSpeed(sequence.lastCheckpoint.speed, this.parameters), ); if (phase instanceof DescendingDeceleration) { diff --git a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/profile/NavGeometryProfile.ts b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/profile/NavGeometryProfile.ts index 5343b97adb3..51cdc4a8782 100644 --- a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/profile/NavGeometryProfile.ts +++ b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/profile/NavGeometryProfile.ts @@ -9,6 +9,7 @@ import { isAltitudeConstraintMet } from '@fmgc/guidance/vnav/descent/DescentPath import { FlightPlanService } from '@fmgc/flightplanning/FlightPlanService'; import { AltitudeConstraint, SpeedConstraint } from '@fmgc/flightplanning/data/constraint'; import { AltitudeDescriptor } from '@flybywiresim/fbw-sdk'; +import { FlightPlanLeg } from '@fmgc/flightplanning/legs/FlightPlanLeg'; // TODO: Merge this with VerticalCheckpoint export interface VerticalWaypointPrediction { @@ -128,6 +129,7 @@ export interface MaxSpeedConstraint { export interface DescentAltitudeConstraint { distanceFromStart: NauticalMiles; constraint: AltitudeConstraint; + leg: FlightPlanLeg; } export interface GeographicCruiseStep { diff --git a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/profile/TemporaryCheckpointSequence.ts b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/profile/TemporaryCheckpointSequence.ts index d605b8cd845..57961f0bf08 100644 --- a/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/profile/TemporaryCheckpointSequence.ts +++ b/fbw-a32nx/src/systems/fmgc/src/guidance/vnav/profile/TemporaryCheckpointSequence.ts @@ -82,7 +82,7 @@ export class TemporaryCheckpointSequence { return this.checkpoints[0].altitude; } - for (let i = 1; i < this.checkpoints.length - 1; i++) { + for (let i = 1; i < this.checkpoints.length; i++) { if (distanceFromStart >= this.checkpoints[i].distanceFromStart) { return Common.interpolate( distanceFromStart, diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMC/FlightManagementComputer.ts b/fbw-a380x/src/systems/instruments/src/MFD/FMC/FlightManagementComputer.ts index c2e05a204e2..886a63da3a5 100644 --- a/fbw-a380x/src/systems/instruments/src/MFD/FMC/FlightManagementComputer.ts +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMC/FlightManagementComputer.ts @@ -890,6 +890,7 @@ export class FlightManagementComputer implements FmcInterface { this.acInterface.updatePerfSpeeds(); this.acInterface.updateWeights(); this.acInterface.toSpeedsChecks(); + this.acInterface.checkTooSteepPath(); const toFlaps = this.fmgc.getTakeoffFlapsSetting(); if (toFlaps) { diff --git a/fbw-a380x/src/systems/instruments/src/MFD/FMC/FmcAircraftInterface.ts b/fbw-a380x/src/systems/instruments/src/MFD/FMC/FmcAircraftInterface.ts index e2d1092e4d3..94436dadc13 100644 --- a/fbw-a380x/src/systems/instruments/src/MFD/FMC/FmcAircraftInterface.ts +++ b/fbw-a380x/src/systems/instruments/src/MFD/FMC/FmcAircraftInterface.ts @@ -1780,6 +1780,24 @@ export class FmcAircraftInterface { return SimVar.SetSimVarValue('L:A32NX_FM_LS_COURSE', 'number', course); } + + private hasTooSteepPathAhead = false; + + checkTooSteepPath() { + const hasTooSteepPathAhead = this.fmc.guidanceController?.vnavDriver?.shouldShowTooSteepPathAhead(); + + if (hasTooSteepPathAhead !== this.hasTooSteepPathAhead) { + this.hasTooSteepPathAhead = hasTooSteepPathAhead; + + if (hasTooSteepPathAhead) { + this.fmc.addMessageToQueue( + NXSystemMessages.tooSteepPathAhead, + () => !this.fmc.guidanceController?.vnavDriver?.shouldShowTooSteepPathAhead(), + undefined, + ); + } + } + } } class FmArinc429OutputWord extends Arinc429Word { diff --git a/fbw-a380x/src/systems/instruments/src/MFD/pages/FMS/F-PLN/MfdFmsFpln.tsx b/fbw-a380x/src/systems/instruments/src/MFD/pages/FMS/F-PLN/MfdFmsFpln.tsx index 165dcc272c6..b3f07bfacc4 100644 --- a/fbw-a380x/src/systems/instruments/src/MFD/pages/FMS/F-PLN/MfdFmsFpln.tsx +++ b/fbw-a380x/src/systems/instruments/src/MFD/pages/FMS/F-PLN/MfdFmsFpln.tsx @@ -362,6 +362,14 @@ export class MfdFmsFpln extends FmsPage { leg?.calculated?.cumulativeDistanceWithTransitions ?? lastDistanceFromStart + (this.derivedFplnLegData[i].distanceFromLastWpt ?? 0) - reduceDistanceBy; this.lineData.push(data); + + if (leg.calculated?.endsInTooSteepPath) { + this.lineData.push({ + type: FplnLineType.Special, + originalLegIndex: null, + label: 'TOO STEEP PATH', + }); + } } else { const data: FplnLineSpecialDisplayData = { type: FplnLineType.Special, diff --git a/fbw-a380x/src/systems/instruments/src/MFD/pages/FMS/F-PLN/MfdFmsFplnVertRev.tsx b/fbw-a380x/src/systems/instruments/src/MFD/pages/FMS/F-PLN/MfdFmsFplnVertRev.tsx index 2f02e40a73d..45dd0f993bd 100644 --- a/fbw-a380x/src/systems/instruments/src/MFD/pages/FMS/F-PLN/MfdFmsFplnVertRev.tsx +++ b/fbw-a380x/src/systems/instruments/src/MFD/pages/FMS/F-PLN/MfdFmsFplnVertRev.tsx @@ -165,6 +165,10 @@ export class MfdFmsFplnVertRev extends FmsPage { 1; if (wptIdx !== undefined) { const leg = this.loadedFlightPlan.legElementAt(wptIdx); + const previousElement = this.loadedFlightPlan.maybeElementAt(wptIdx - 1); + const isPartOfTooSteepPathSegment = + leg.calculated?.endsInTooSteepPath || + (previousElement?.isDiscontinuity === false && previousElement.calculated?.endsInTooSteepPath); if (!MfdFmsFplnVertRev.isEligibleForVerticalRevision(wptIdx, leg, this.loadedFlightPlan)) { this.speedMessageArea.set(`SPD CSTR NOT ALLOWED AT ${leg.ident}`); @@ -175,7 +179,7 @@ export class MfdFmsFplnVertRev extends FmsPage { } this.speedMessageArea.set(''); this.spdConstraintDisabled.set(false); - this.altitudeMessageArea.set(''); + this.altitudeMessageArea.set(isPartOfTooSteepPathSegment ? 'TOO STEEP PATH AHEAD' : ''); this.altConstraintDisabled.set(false); // Load speed constraints diff --git a/fbw-a380x/src/systems/instruments/src/MFD/shared/NXSystemMessages.ts b/fbw-a380x/src/systems/instruments/src/MFD/shared/NXSystemMessages.ts index f99c82da4e2..0360ddae63d 100644 --- a/fbw-a380x/src/systems/instruments/src/MFD/shared/NXSystemMessages.ts +++ b/fbw-a380x/src/systems/instruments/src/MFD/shared/NXSystemMessages.ts @@ -110,6 +110,7 @@ export const NXSystemMessages = { stepAboveMaxFl: new TypeIIMessage('STEP ABOVE MAX FL'), stepAhead: new TypeIIMessage('STEP AHEAD'), stepDeleted: new TypeIIMessage('STEP DELETED'), + tooSteepPathAhead: new TypeIIMessage('TOO STEEP PATH AHEAD'), }; export const NXFictionalMessages = { diff --git a/fbw-common/src/systems/shared/src/FmMessages.ts b/fbw-common/src/systems/shared/src/FmMessages.ts index d99acfb683f..379ef464dc2 100644 --- a/fbw-common/src/systems/shared/src/FmMessages.ts +++ b/fbw-common/src/systems/shared/src/FmMessages.ts @@ -218,4 +218,10 @@ export const FMMessageTypes: Readonly> = { color: 'White', clearable: true, }, + TooSteepPathAhead: { + id: 20, + text: 'TOO STEEP PATH AHEAD', + color: 'Amber', + clearable: true, + }, }; diff --git a/fbw-common/src/systems/shared/src/MathUtils.ts b/fbw-common/src/systems/shared/src/MathUtils.ts index a835dee0608..30b79c90bfb 100644 --- a/fbw-common/src/systems/shared/src/MathUtils.ts +++ b/fbw-common/src/systems/shared/src/MathUtils.ts @@ -595,4 +595,57 @@ export class MathUtils { return MathUtils.interpolate(j, table[0][c1], table[0][c2], interpolatedRowAtC1, interpolatedRowAtC2); } + + /** + * Checks whether two numbers are within a certain epsilon of each other. + * @param a + * @param b + * @param epsilon the absolute tolerance + * @returns true if the numbers are within epsilon of each other + */ + public static isAboutEqual(a: number, b: number, epsilon = 1e-4): boolean { + return Math.abs(a - b) < epsilon; + } + + /** + * Checks whether a number is positive and within a certain epsilon of zero. + * @param num + * @param epsilon the absolute tolerance + * @returns true if the number is positive and within epsilon of zero + */ + public static isCloseToPositive(num: number, epsilon = 1e-4): boolean { + return num > -Math.abs(epsilon); + } + + /** + * Checks whether a number is negative and within a certain epsilon of zero. + * @param num + * @param epsilon the absolute tolerance + * @returns true if the number is negative and within epsilon of zero + */ + public static isCloseToNegative(num: number, epsilon = 1e-4): boolean { + return this.isCloseToPositive(-num, epsilon); + } + + /** + * Checks whether a > b or a is within a certain epsilon of b. + * @param a + * @param b + * @param epsilon the absolute tolerance + * @returns true if the number is greater than or within epsilon of the other + */ + public static isCloseToGreaterThan(a: number, b: number, epsilon = 1e-4): boolean { + return this.isCloseToPositive(a - b, epsilon); + } + + /** + * Checks whether a < b or a is within a certain epsilon of b. + * @param a + * @param b + * @param epsilon the absolute tolerance + * @returns true if the number is less than or within epsilon of the other + */ + public static isCloseToLessThan(a: number, b: number, epsilon = 1e-4): boolean { + return this.isCloseToNegative(a - b, epsilon); + } }