diff --git a/fbw-common/src/systems/instruments/src/EFB/Localization/data/en.json b/fbw-common/src/systems/instruments/src/EFB/Localization/data/en.json index dc4e3590348..3ddd219a33b 100644 --- a/fbw-common/src/systems/instruments/src/EFB/Localization/data/en.json +++ b/fbw-common/src/systems/instruments/src/EFB/Localization/data/en.json @@ -406,6 +406,13 @@ "Wind": "Wind", "WindMagnitudeUnit": "kts or °/kts" }, + "TemperatureCorrection": { + "CorrectedAltitudes": "Corrected Altitudes", + "FieldElevation": "Field/THR Elevation", + "NoRunway": "Unknown", + "PublishedAltitudes": "Published Altitudes", + "Title": "Temperature Correction" + }, "Title": "Performance", "TopOfDescent": { "Data": { diff --git a/fbw-common/src/systems/instruments/src/EFB/Performance/Data/Runways.ts b/fbw-common/src/systems/instruments/src/EFB/Performance/Data/Runways.ts index 6299ad44d57..555a7e62105 100644 --- a/fbw-common/src/systems/instruments/src/EFB/Performance/Data/Runways.ts +++ b/fbw-common/src/systems/instruments/src/EFB/Performance/Data/Runways.ts @@ -32,7 +32,7 @@ function mapRunwayDesignator(designatorChar: RunwayDesignatorChar) { } } -function getAirport(icao: string): Promise { +export function getAirport(icao: string): Promise { icao = icao.toUpperCase(); return new Promise((resolve, reject) => { if (icao.length !== 4) { diff --git a/fbw-common/src/systems/instruments/src/EFB/Performance/Performance.tsx b/fbw-common/src/systems/instruments/src/EFB/Performance/Performance.tsx index 253e1fb0aba..6f4335e2467 100644 --- a/fbw-common/src/systems/instruments/src/EFB/Performance/Performance.tsx +++ b/fbw-common/src/systems/instruments/src/EFB/Performance/Performance.tsx @@ -10,6 +10,7 @@ import { LandingWidget } from './Widgets/LandingWidget'; import { TakeoffWidget } from './Widgets/TakeoffWidget'; import { TabRoutes, PageLink, PageRedirect } from '../Utils/routing'; import { AircraftContext } from '../AircraftContext'; +import { TemperatureCorrectionWidget } from 'instruments/src/EFB/Performance/Widgets/TemperatureCorrectionWidget'; export const Performance = () => { const calculators = useContext(AircraftContext).performanceCalculators; @@ -22,6 +23,11 @@ export const Performance = () => { calculators.landing ? { name: 'Landing', alias: t('Performance.Landing.Title'), component: } : null, + { + name: 'Temperature Correction', + alias: t('Performance.TemperatureCorrection.Title'), + component: , + }, ].filter((t) => t !== null); return ( diff --git a/fbw-common/src/systems/instruments/src/EFB/Performance/Widgets/TemperatureCorrectionWidget.tsx b/fbw-common/src/systems/instruments/src/EFB/Performance/Widgets/TemperatureCorrectionWidget.tsx new file mode 100644 index 00000000000..8f72633acb0 --- /dev/null +++ b/fbw-common/src/systems/instruments/src/EFB/Performance/Widgets/TemperatureCorrectionWidget.tsx @@ -0,0 +1,337 @@ +// Copyright (c) 2025 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import React, { FC, useState } from 'react'; +import { Metar as FbwApiMetar } from '@flybywiresim/api-client'; +import { Metar as MsfsMetar } from '@microsoft/msfs-sdk'; +import { + Units, + MetarParserType, + usePersistentProperty, + parseMetar, + ConfigWeatherMap, + MathUtils, +} from '@flybywiresim/fbw-sdk'; +import { toast } from 'react-toastify'; +import { CloudArrowDown } from 'react-bootstrap-icons'; +import { getAirport } from '../Data/Runways'; +import { t } from '../../Localization/translation'; +import { TooltipWrapper } from '../../UtilComponents/TooltipWrapper'; +import { SimpleInput } from '../../UtilComponents/Form/SimpleInput/SimpleInput'; +import { SelectInput } from '../../UtilComponents/Form/SelectInput/SelectInput'; +import { useAppDispatch, useAppSelector } from '../../Store/store'; +import { + setFieldElevation, + setTemperature, + setPublishedAltitudes, + setIcao, +} from '../../Store/features/temperatureCorrectionCalculator'; + +interface LabelProps { + className?: string; + text: string; +} + +const NUMBER_OF_ALTITUDES = 9; + +const Label: FC = ({ text, className, children }) => ( +
+

{text}

+ {children} +
+); + +export const TemperatureCorrectionWidget = () => { + const dispatch = useAppDispatch(); + + const [autoFillSource, setAutoFillSource] = useState<'METAR' | 'OFP'>('OFP'); + const [metarSource] = usePersistentProperty('CONFIG_METAR_SRC', 'MSFS'); + const { usingMetric: usingMetricPinProg } = Units; + + const { icao, temperature, fieldElevation, publishedAltitudes } = useAppSelector( + (state) => state.temperatureCorrectionCalculator, + ); + + const { arrivingAirport: ofpArrivingAirport, arrivingMetar: ofpArrivingMetar } = useAppSelector( + (state) => state.simbrief.data, + ); + + const isValidIcao = (icao: string): boolean => icao?.length === 4; + + const handleICAOChange = (icao: string) => { + dispatch(setIcao(icao)); + if (isValidIcao(icao)) { + getAirport(icao) + .then((airport) => dispatch(setFieldElevation(airport.altitude))) + .catch(() => dispatch(setFieldElevation(undefined))); + } + }; + + const syncValuesWithApiMetar = async (): Promise => { + if (!isValidIcao(icao)) { + return; + } + + let parsedMetar: MetarParserType | undefined = undefined; + + // Comes from the sim rather than the FBW API + if (metarSource === 'MSFS') { + let metar: MsfsMetar; + try { + metar = await Coherent.call('GET_METAR_BY_IDENT', icao); + if (metar.icao !== icao.toUpperCase()) { + throw new Error('No METAR available'); + } + parsedMetar = parseMetar(metar.metarString); + } catch (err) { + toast.error(err.message); + } + } else { + try { + const response = await FbwApiMetar.get(icao, ConfigWeatherMap[metarSource]); + if (!response.metar) { + throw new Error('No METAR available'); + } + parsedMetar = parseMetar(response.metar); + } catch (err) { + toast.error(err.message); + } + } + + if (parsedMetar === undefined) { + return; + } + + dispatch(setTemperature(parsedMetar.temperature.celsius)); + }; + + const handleFieldElevation = (input: string): void => { + let elevation: number | undefined = parseInt(input); + + if (Number.isNaN(elevation)) { + elevation = undefined; + } + + dispatch(setFieldElevation(elevation)); + }; + + const handleTemperature = (input: string): void => { + let temperature: number | undefined = parseInt(input); + + if (Number.isNaN(temperature)) { + temperature = undefined; + } + + dispatch(setTemperature(temperature)); + }; + + const handlePublishedAltitude = (index: number, input: string): void => { + let altitude: number | undefined = parseInt(input); + + if (Number.isNaN(altitude)) { + altitude = undefined; + } + + const altitudes = [...publishedAltitudes.slice(0, index), altitude, ...publishedAltitudes.slice(index + 1)]; + + dispatch(setPublishedAltitudes(altitudes)); + }; + + const syncValuesWithOfp = async () => { + if (!isValidIcao(ofpArrivingAirport)) { + return; + } + + const parsedMetar: MetarParserType = parseMetar(ofpArrivingMetar); + try { + const airport = await getAirport(ofpArrivingAirport); + dispatch(setFieldElevation(airport.altitude)); + dispatch(setTemperature(parsedMetar.temperature.celsius)); + } catch (e) { + toast.error(e); + dispatch(setFieldElevation(undefined)); + } + }; + + const handleAutoFill = () => { + if (autoFillSource === 'METAR') { + syncValuesWithApiMetar(); + } else { + syncValuesWithOfp(); + } + }; + + const isAutoFillIcaoValid = () => { + if (autoFillSource === 'METAR') { + return isValidIcao(icao); + } + return isValidIcao(ofpArrivingAirport); + }; + + const calculateCorrectedAltitude = (publishedAlt?: number): number | undefined => { + if (publishedAlt === undefined || fieldElevation === undefined || temperature === undefined) { + return undefined; + } + + // Formula from EUROCONTROL 2940 workbook. + const correction = + (publishedAlt - fieldElevation) * + ((15 - (temperature + 0.00198 * fieldElevation)) / + (273 + + (temperature + 0.00198 * fieldElevation) - + 0.5 * 0.00198 * (publishedAlt - fieldElevation + fieldElevation))); + + if (correction <= 0) { + return publishedAlt; + } + + return MathUtils.ceil(publishedAlt + correction, 10); + }; + + const [temperatureUnit, setTemperatureUnit] = usePersistentProperty( + 'EFB_PREFERRED_TEMPERATURE_UNIT', + usingMetricPinProg ? 'C' : 'F', + ); + + const getVariableUnitDisplayValue = ( + value: number | undefined, + unit: T, + imperialUnit: T, + metricToImperial: (value: number) => number, + ) => { + if (value !== undefined) { + if (unit === imperialUnit) { + return metricToImperial(value); + } + return value; + } + return undefined; + }; + + const fillDataTooltip = () => { + switch (autoFillSource) { + case 'METAR': + if (!isAutoFillIcaoValid()) { + return t('Performance.Landing.TT.YouNeedToEnterAnIcaoCodeInOrderToMakeAMetarRequest'); + } + break; + case 'OFP': + if (!isAutoFillIcaoValid()) { + return t('Performance.Landing.TT.YouNeedToLoadSimBriefDataInOrderToAutofillData'); + } + break; + default: + return undefined; + } + + return undefined; + }; + + return ( +
+
+
+
+
+
+
+ + + + setAutoFillSource(value)} + /> +
+
+
+
+
+ +
+ +
+
+ +
+
+
+
+
+
{t('Performance.TemperatureCorrection.PublishedAltitudes')}
+
{t('Performance.TemperatureCorrection.CorrectedAltitudes')}
+ {Array.from({ length: NUMBER_OF_ALTITUDES }).map((_, idx) => ( + <> + handlePublishedAltitude(idx, v)} + number + /> +
{calculateCorrectedAltitude(publishedAltitudes[idx])}
+ + ))} +
+
+
+ ); +}; diff --git a/fbw-common/src/systems/instruments/src/EFB/Store/features/temperatureCorrectionCalculator.ts b/fbw-common/src/systems/instruments/src/EFB/Store/features/temperatureCorrectionCalculator.ts new file mode 100644 index 00000000000..50f107b9195 --- /dev/null +++ b/fbw-common/src/systems/instruments/src/EFB/Store/features/temperatureCorrectionCalculator.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2025 FlyByWire Simulations +// SPDX-License-Identifier: GPL-3.0 + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface TemperatureCorrectionCalculatorState { + icao?: string; + temperature?: number; + fieldElevation?: number; + publishedAltitudes: (number | undefined)[]; +} + +const initialState: TemperatureCorrectionCalculatorState = { + publishedAltitudes: [], +}; + +export const temperatureCorrectionCalculatorSlice = createSlice({ + name: 'temperatureCorrectionCalculator', + initialState, + reducers: { + setTemperature: (state, action: PayloadAction) => { + state.temperature = action.payload; + }, + setFieldElevation: (state, action: PayloadAction) => { + state.fieldElevation = action.payload; + }, + setPublishedAltitudes: ( + state, + action: PayloadAction, + ) => { + state.publishedAltitudes = action.payload; + }, + setIcao: (state, action: PayloadAction) => { + state.icao = action.payload; + }, + }, +}); + +export const { setTemperature, setFieldElevation, setPublishedAltitudes, setIcao } = + temperatureCorrectionCalculatorSlice.actions; + +export default temperatureCorrectionCalculatorSlice.reducer; diff --git a/fbw-common/src/systems/instruments/src/EFB/Store/store.ts b/fbw-common/src/systems/instruments/src/EFB/Store/store.ts index 65fdc33b360..57f26c105aa 100644 --- a/fbw-common/src/systems/instruments/src/EFB/Store/store.ts +++ b/fbw-common/src/systems/instruments/src/EFB/Store/store.ts @@ -21,6 +21,7 @@ import tooltipReducer from './features/tooltip'; import pushbackReducer from './features/pushback'; import payloadReducer from './features/payload'; import configReducer from './features/config'; +import temperatureCorrectionCalculatorReducer from './features/temperatureCorrectionCalculator'; export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; @@ -43,6 +44,7 @@ const combinedReducer = combineReducers({ pushback: pushbackReducer, payload: payloadReducer, config: configReducer, + temperatureCorrectionCalculator: temperatureCorrectionCalculatorReducer, }); const rootReducer: Reducer = (state: RootState, action: AnyAction) => { diff --git a/fbw-common/src/systems/shared/src/MathUtils.spec.ts b/fbw-common/src/systems/shared/src/MathUtils.spec.ts index 5ff140552f5..53671c980f1 100644 --- a/fbw-common/src/systems/shared/src/MathUtils.spec.ts +++ b/fbw-common/src/systems/shared/src/MathUtils.spec.ts @@ -26,6 +26,30 @@ describe('MathUtils.round', () => { }); }); +describe('MathUtils.ceil', () => { + it('correctly rounds up', () => { + expect(MathUtils.ceil(1.005, 2)).toBeCloseTo(2); + expect(MathUtils.ceil(1.005, 3)).toBeCloseTo(3); + expect(MathUtils.ceil(1.004, 0.005)).toBeCloseTo(1.005, 3); + expect(MathUtils.ceil(1.5, 0)).toBe(NaN); + expect(MathUtils.ceil(1.05, 1)).toBeCloseTo(2); + expect(MathUtils.ceil(1.33, 0.25)).toBeCloseTo(1.5); + expect(MathUtils.ceil(1.38, 0.25)).toBeCloseTo(1.5); + }); +}); + +describe('MathUtils.floor', () => { + it('correctly rounds down', () => { + expect(MathUtils.floor(1.005, 2)).toBeCloseTo(0); + expect(MathUtils.floor(1.005, 3)).toBeCloseTo(0); + expect(MathUtils.floor(1.004, 0.005)).toBeCloseTo(1.0, 3); + expect(MathUtils.floor(1.5, 0)).toBe(NaN); + expect(MathUtils.floor(1.05, 1)).toBeCloseTo(1); + expect(MathUtils.floor(1.33, 0.25)).toBeCloseTo(1.25); + expect(MathUtils.floor(1.38, 0.25)).toBeCloseTo(1.25); + }); +}); + describe('MathUtils.angleAdd', () => { it('correctly adds two angles', () => { expect(MathUtils.angleAdd(270, 90)).toBeCloseTo(360, 4); diff --git a/fbw-common/src/systems/shared/src/MathUtils.ts b/fbw-common/src/systems/shared/src/MathUtils.ts index a835dee0608..2baf325e1ab 100644 --- a/fbw-common/src/systems/shared/src/MathUtils.ts +++ b/fbw-common/src/systems/shared/src/MathUtils.ts @@ -552,6 +552,26 @@ export class MathUtils { return Math.round(value / quantum) * quantum; } + /** + * Round a number up to a specified quantum. + * @param value The number to round. + * @param quantum The quantum to round to, defaults to 1. + * @returns The rounded number. + */ + public static ceil(value: number, quantum = 1): number { + return Math.ceil(value / quantum) * quantum; + } + + /** + * Round a number down to a specified quantum. + * @param value The number to round. + * @param quantum The quantum to round to, defaults to 1. + * @returns The rounded number. + */ + public static floor(value: number, quantum = 1): number { + return Math.floor(value / quantum) * quantum; + } + static interpolate(x: number, x0: number, x1: number, y0: number, y1: number): number { return (y0 * (x1 - x) + y1 * (x - x0)) / (x1 - x0); } diff --git a/fbw-common/src/typings/flybywire-vcockpits-instruments/html_ui/Pages/VCockpit/Instruments/Shared/FlightElements/navdata.d.ts b/fbw-common/src/typings/flybywire-vcockpits-instruments/html_ui/Pages/VCockpit/Instruments/Shared/FlightElements/navdata.d.ts index d3b329fafec..aa802fc6302 100644 --- a/fbw-common/src/typings/flybywire-vcockpits-instruments/html_ui/Pages/VCockpit/Instruments/Shared/FlightElements/navdata.d.ts +++ b/fbw-common/src/typings/flybywire-vcockpits-instruments/html_ui/Pages/VCockpit/Instruments/Shared/FlightElements/navdata.d.ts @@ -217,6 +217,7 @@ declare global { radarCoverage: number; runways: RawRunway[]; towered: boolean; + altitude: number; __Type: 'JS_FacilityAirport'; }