flight-planner
Version:
Plan and route VFR flights
355 lines (354 loc) • 14.8 kB
JavaScript
import { createWaypoint, waypointDistance, waypointHeading } from './waypoint.js';
import { calculateGroundspeed, calculateWindCorrectionAngle, calculateWindVector } from './utils.js';
import { bearingToAzimuth, lineString, point as turfPoint, pointToLineDistance } from '@turf/turf';
import { InsufficientWaypointsError } from './exceptions.js';
/**
* Checks if a given track is eastbound (0-179 degrees).
*
* @param {number} track - The track in degrees.
* @returns {boolean} True if the track is eastbound, false otherwise.
*/
export const isEastbound = (track) => {
const normalizedTrack = bearingToAzimuth(track);
return normalizedTrack >= 0 && normalizedTrack <= 179;
};
/**
* Checks if a given track is westbound (180-359 degrees).
*
* @param {number} track - The track in degrees.
* @returns {boolean} True if the track is westbound, false otherwise.
*/
export const isWestbound = (track) => {
return !isEastbound(track);
};
/**
* Calculates the appropriate VFR cruising altitude based on the track and desired minimum altitude.
*
* Eastbound flights (0-179 degrees) use odd thousands + 500 feet (e.g., 3500, 5500).
* Westbound flights (180-359 degrees) use even thousands + 500 feet (e.g., 4500, 6500).
* The function returns the lowest VFR cruising altitude that is at or above the given minimum altitude.
*
* @param {number} track - The true track in degrees.
* @param {number} altitude - The minimum desired altitude in feet.
* @returns {number} The calculated VFR cruising altitude in feet.
*/
export function calculateVFRCruisingAltitude(track, altitude) {
let altitudeLevel;
if (isEastbound(track)) {
altitudeLevel = 3500;
while (altitudeLevel < altitude) {
altitudeLevel += 2000;
}
}
else {
altitudeLevel = 4500;
while (altitudeLevel < altitude) {
altitudeLevel += 2000;
}
}
return altitudeLevel;
}
/**
* Converts an altitude in feet to the corresponding flight level.
* Flight levels are expressed in hundreds of feet, so the function divides the altitude by 100.
*
* @param {number} altitude - The altitude in feet.
* @returns {number} The flight level (FL) corresponding to the given altitude.
*/
export const flightLevel = (altitude) => {
return Math.floor(altitude / 100);
};
/**
* Finds the closest route leg to a given location.
*
* @param routeTrip - The route trip to search within.
* @param location - The location to find the closest leg to, as a [longitude, latitude] tuple.
* @returns The closest route leg, or undefined if no route legs are found or the input is invalid.
*/
export function closestRouteLeg(routeTrip, location) {
if (!routeTrip || !routeTrip.route || routeTrip.route.length === 0) {
return undefined;
}
const targetPointFeature = turfPoint(location);
let minDistanceToLeg = Infinity;
let closestLeg = undefined;
for (const leg of routeTrip.route) {
const startWp = leg.start.waypoint;
const endWp = leg.end.waypoint;
if (!startWp.coords || !endWp.coords) {
continue;
}
const legLine = lineString([startWp.coords, endWp.coords]);
const distance = pointToLineDistance(targetPointFeature, legLine, { units: 'nauticalmiles' });
if (distance < minDistanceToLeg) {
minDistanceToLeg = distance;
closestLeg = leg;
}
}
return closestLeg;
}
/**
* Finds the closest waypoint in a route trip to a given location.
*
* @param routeTrip - The route trip to search within.
* @param location - The location to find the closest waypoint to, as a [longitude, latitude] tuple.
* @returns The closest waypoint, or undefined if no waypoints are found or the input is invalid.
*/
export function closestWaypoint(routeTrip, location) {
if (!routeTrip || !routeTrip.route || routeTrip.route.length === 0) {
return undefined;
}
const currentLocationAsWaypoint = createWaypoint(location, 'currentLocationPoint');
let minDistance = Infinity;
let closestWaypoint = undefined;
const uniqueWaypoints = routeTripWaypoints(routeTrip);
for (const wp of uniqueWaypoints) {
if (!wp.coords) {
continue;
}
const dist = waypointDistance(currentLocationAsWaypoint, wp);
if (dist < minDistance) {
minDistance = dist;
closestWaypoint = wp;
}
}
return closestWaypoint;
}
/**
* Maps a route trip to an array of unique waypoints.
*
* This function extracts all waypoints from a route trip by taking the start and end
* waypoints of each leg and removing duplicates.
*
* @param routeTrip - The route trip containing legs with start and end waypoints
* @returns An array of unique waypoints representing all points in the route trip
*/
export function routeTripWaypoints(routeTrip) {
const allWaypoints = routeTrip.route.flatMap(leg => [leg.start.waypoint, leg.end.waypoint]);
const uniqueWaypoints = new Map();
for (const waypoint of allWaypoints) {
uniqueWaypoints.set(waypoint.name, waypoint);
}
return Array.from(uniqueWaypoints.values());
}
/**
* Converts an array of waypoints to an array of route segments.
*
* This is a convenience function for preparing waypoints to be used with `calculateNavLog`.
* Each waypoint is converted to a segment with an optional altitude.
*
* @param waypoints - Array of waypoints to convert to segments
* @param altitude - Optional altitude in feet to apply to all segments
* @returns Array of route segments ready for navigation log calculation
*
* @example
* ```typescript
* const waypoints = [departureAerodrome, enrouteWaypoint, arrivalAerodrome];
* const segments = waypointsToSegments(waypoints, 5500);
* const navLog = calculateNavLog({ segments, aircraft });
* ```
*/
export function waypointsToSegments(waypoints, altitude) {
return waypoints.map(waypoint => ({
waypoint,
...(altitude !== undefined && { altitude })
}));
}
/**
* Gets the departure waypoint from a route trip.
*
* @param routeTrip - The route trip from which to extract the departure waypoint
* @returns The departure waypoint, which is the first waypoint in the route
*/
export const routeTripDepartureWaypoint = (routeTrip) => {
return routeTrip.route[0].start.waypoint;
};
/**
* Gets the arrival waypoint from a route trip.
*
* @param routeTrip - The route trip from which to extract the arrival waypoint
* @returns The arrival waypoint, which is the last waypoint in the route
*/
export const routeTripArrivalWaypoint = (routeTrip) => {
return routeTrip.route[routeTrip.route.length - 1].end.waypoint;
};
/**
* Calculates a detailed navigation log (nav log) based on the provided options.
*
* A navigation log contains comprehensive flight performance calculations for each leg of the route,
* including wind corrections, heading calculations, groundspeed, fuel consumption, and timing.
* This is the detailed computational output that pilots use for in-flight navigation, as opposed
* to a flight plan which is the route description submitted to ATC.
*
* The function performs the following calculations:
* - Route leg analysis: distance, true/magnetic tracks, and headings
* - Wind correction angles and ground speeds based on forecast winds
* - Fuel planning: trip fuel, reserves, taxi, takeoff, landing, and alternate fuel
* - Timing: leg durations, departure/arrival times
* - Performance metrics: true airspeed, headwind/crosswind components
*
* @param options - Navigation log options containing segments, aircraft, and fuel parameters.
* @returns A RouteTrip object containing the complete navigation log with all calculated performance data.
* @throws {InsufficientWaypointsError} If fewer than 2 waypoints are provided in segments.
*
* @example
* ```typescript
* const navLog = calculateNavLog({
* segments: [
* { waypoint: departureAerodrome },
* { waypoint: enrouteWaypoint, altitude: 5500 },
* { waypoint: arrivalAerodrome }
* ],
* aircraft: { cruiseSpeed: 120, fuelConsumption: 8.5 },
* altitude: 5500,
* reserveFuelDuration: 45
* });
* ```
*/
export function calculateNavLog(options) {
const { segments: inputSegments, alternateSegment: inputAlternateSegment, aircraft, departureDate = new Date(), altitude, reserveFuelDuration = 30, reserveFuel, taxiFuel, takeoffFuel, landingFuel } = options;
if (inputSegments.length < 2) {
throw new InsufficientWaypointsError(inputSegments.length);
}
const segments = inputSegments.map(seg => ({ ...seg }));
const alternateSegment = inputAlternateSegment ? { ...inputAlternateSegment } : undefined;
if (segments[0].waypoint.elevation !== undefined) {
segments[0].altitude = segments[0].waypoint.elevation;
}
if (segments[segments.length - 1].waypoint.elevation !== undefined) {
segments[segments.length - 1].altitude = segments[segments.length - 1].waypoint.elevation;
}
if (altitude) {
segments.forEach((segment, i) => {
if (i !== 0 && i !== segments.length - 1 && !segment.altitude) {
segment.altitude = altitude;
}
});
}
const aircraftPerformance = aircraft?.cruiseSpeed ? {
cruiseSpeed: aircraft.cruiseSpeed,
fuelConsumption: aircraft.fuelConsumption
} : undefined;
const lastLegIndex = segments.length - 2;
const legs = segments.slice(0, -1).map((startSegment, i) => calculateRouteLeg(startSegment, segments[i + 1], aircraftPerformance, i === lastLegIndex));
let routeAlternate;
if (alternateSegment) {
if (altitude && !alternateSegment.altitude) {
alternateSegment.altitude = altitude;
}
routeAlternate = calculateRouteLeg(segments[segments.length - 1], alternateSegment, aircraftPerformance, true);
}
let totalDistance = 0;
let totalDuration = 0;
let totalFuelConsumption = 0;
for (const leg of legs) {
totalDistance += leg.course.distance;
totalDuration += leg.performance?.duration || 0;
totalFuelConsumption += leg.performance?.fuelConsumption || 0;
const legArrivalDate = leg.performance && new Date(departureDate.getTime() + totalDuration * 60 * 1000);
leg.arrivalDate = legArrivalDate;
}
const reserveFuelRequired = reserveFuel ?? (aircraft?.fuelConsumption ? aircraft.fuelConsumption * (reserveFuelDuration / 60) : 0);
const alternateFuel = routeAlternate?.performance?.fuelConsumption || 0;
const totalTripFuel = totalFuelConsumption
+ (reserveFuelRequired || 0)
+ (takeoffFuel || 0)
+ (landingFuel || 0)
+ (taxiFuel || 0)
+ alternateFuel;
const fuelBreakdown = {
trip: totalFuelConsumption,
reserve: reserveFuelRequired,
takeoff: takeoffFuel !== undefined ? takeoffFuel : undefined,
landing: landingFuel !== undefined ? landingFuel : undefined,
taxi: taxiFuel !== undefined ? taxiFuel : undefined,
alternate: routeAlternate?.performance?.fuelConsumption !== undefined ? routeAlternate.performance.fuelConsumption : undefined
};
return {
route: legs,
routeAlternate,
totalDistance,
totalDuration,
totalTripFuel,
fuelBreakdown,
departureDate,
arrivalDate: new Date(departureDate.getTime() + totalDuration * 60 * 1000),
generatedAt: new Date(),
};
}
/**
* Calculates a single leg of a flight route.
*
* @param {RouteSegment} start - The starting segment of the leg.
* @param {RouteSegment} end - The ending segment of the leg.
* @param {AircraftPerformance} [aircraft] - The aircraft performance data for calculations.
* @param {boolean} [isLastLeg=false] - Indicates whether this is the last leg of the route; when true, the end waypoint's wind data is used instead of the start waypoint's.
* @returns {RouteLeg} The calculated route leg.
*/
function calculateRouteLeg(start, end, aircraft, isLastLeg = false) {
const course = calculateRouteCourse(start.waypoint, end.waypoint);
// Use start waypoint wind for most legs; use end waypoint wind for the
// last leg so arrival wind checks reflect the destination weather.
const wind = isLastLeg
? end.waypoint.metarStation?.metar.wind
: start.waypoint.metarStation?.metar.wind;
const performance = aircraft && wind ? calculatePerformance(aircraft, course, wind) : undefined;
return {
start,
end,
course,
wind,
performance,
};
}
/**
* Calculates the course vector (distance and track) between two waypoints.
*
* @param {Waypoint} start - The starting waypoint.
* @param {Waypoint} end - The ending waypoint.
* @returns {CourseVector} The calculated course vector.
*/
function calculateRouteCourse(start, end) {
const trueTrack = waypointHeading(start, end);
const magneticDeclination = start.declination
|| end.declination
|| 0;
return {
distance: waypointDistance(start, end),
track: bearingToAzimuth(trueTrack),
magneticTrack: bearingToAzimuth(trueTrack - magneticDeclination),
};
}
/**
* Calculates aircraft performance for a given course, aircraft performance data, and wind conditions.
*
* @param {AircraftPerformance} aircraft - The aircraft performance data (cruise speed and fuel consumption).
* @param {CourseVector} course - The course vector (track and distance).
* @param {Wind} wind - The wind conditions.
* @returns {RouteLegPerformance} The calculated aircraft performance.
*/
function calculatePerformance(aircraft, course, wind) {
// Calculate the true airspeed
const trueAirspeed = aircraft.cruiseSpeed;
// Calculate wind correction angle and magnetic heading
const wca = calculateWindCorrectionAngle(wind, course.track, trueAirspeed);
const trueHeading = bearingToAzimuth(course.track + wca);
const magneticHeading = bearingToAzimuth(course.magneticTrack + wca);
// Groundspeed calculation uses true heading.
const groundSpeed = calculateGroundspeed(wind, trueAirspeed, trueHeading);
const duration = groundSpeed > 0 ? (course.distance / groundSpeed) * 60 : 0;
const fuelConsumption = aircraft.fuelConsumption && (aircraft.fuelConsumption * (duration / 60));
// Calculate wind vector components
const windVector = calculateWindVector(wind, course.track);
return {
headWind: windVector.headwind,
crossWind: windVector.crosswind,
trueAirspeed,
windCorrectionAngle: wca,
trueHeading,
magneticHeading,
groundSpeed,
duration,
fuelConsumption
};
}