UNPKG

flight-planner

Version:
413 lines (412 loc) 18.3 kB
import { routeTripValidate } from './index.js'; import { createWaypoint, waypointDistance, waypointHeading } from './waypoint.js'; import { calculateGroundspeed, calculateWindCorrectionAngle, calculateWindVector, isICAO } from './utils.js'; import { bearingToAzimuth, lineString, point as turfPoint, pointToLineDistance } from '@turf/turf'; /** * 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 const 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; }; // TODO: Maybe convert altitude to pressure altitude /** * Converts an altitude in feet to the corresponding flight level. * Flight levels are typically expressed in hundreds of feet, so the function divides the altitude by 1000. * * @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 / 1000); }; /** * 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 const 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.location?.geometry?.coordinates || !endWp.location?.geometry?.coordinates) { continue; } const legLine = lineString([ startWp.location.geometry.coordinates, endWp.location.geometry.coordinates ]); 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 const 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.location?.geometry?.coordinates) { 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 const 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()); }; /** * 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; }; /** * Parses a route string into an array of waypoints. * * This function accepts a route string containing various waypoint formats and converts them * into standardized waypoint objects. It supports ICAO airport codes and coordinate-based waypoints. * * @async * @function parseRouteString * @param {AerodromeService} aerodromeService - Service for looking up aerodrome information by ICAO codes * @param {string} routeString - The route string to parse, containing waypoints separated by spaces, semicolons, or newlines * @returns {Promise<WaypointType[]>} A promise that resolves to an array of parsed waypoints * * @description * Supported waypoint formats: * - ICAO codes: 4-letter airport identifiers (e.g., "KJFK", "EGLL") * - Coordinate waypoints: WP(latitude,longitude) format (e.g., "WP(40.7128,-74.0060)") * * The function performs the following operations: * 1. Splits the route string by whitespace, semicolons, and newlines * 2. Filters out empty parts * 3. Converts all input to uppercase for consistency * 4. For each part, attempts to match against supported formats: * - If ICAO code: looks up aerodrome using the provided service * - If coordinate waypoint: creates a waypoint with the specified coordinates * 5. Collects parsing errors and throws if no valid waypoints are found * * @throws {Error} Throws an error if the route string cannot be parsed or contains no valid waypoints */ export const parseRouteString = async (aerodromeService, routeString) => { if (!routeString) return []; const waypoints = []; const routeParts = routeString.toUpperCase().split(/[;\s\n]+/).filter(part => part.length > 0); const waypointRegex = /^WP\((-?\d+\.?\d*),(-?\d+\.?\d*)\)$/; const parseErrors = []; for (const part of routeParts) { try { if (isICAO(part)) { const airport = await aerodromeService.findOne(part); if (airport) { waypoints.push(airport); continue; } else { throw new Error(`Could not find aerodrome with ICAO code: ${part}`); } } const waypointMatch = part.match(waypointRegex); if (waypointMatch) { const lat = parseFloat(waypointMatch[1]); const lng = parseFloat(waypointMatch[2]); if (isNaN(lat) || isNaN(lng)) { throw new Error(`Invalid coordinates in waypoint: ${part}`); } const name = `WP-${lat.toFixed(2)},${lng.toFixed(2)}`; waypoints.push({ name, location: turfPoint([lng, lat]) }); continue; } // TODO: Check for things like NAVAIDs, VORs, NDBs, etc. // TODO: Check for VFR waypoints, starting with VRP_XX // const rpRegex = /^([A-Z]+)$/; // const rpMatch = part.match(rpRegex); // if (rpMatch) { // const airport = await aerodromeService.get(part); // waypoints.push(rp); // continue; // } throw new Error(`Unrecognized waypoint format: ${part}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); parseErrors.push(`Error parsing route part "${part}": ${errorMessage}`); console.error(parseErrors[parseErrors.length - 1]); } } if (waypoints.length === 0 && parseErrors.length > 0) { throw new Error(`Failed to parse route string: ${parseErrors.join('; ')}`); } return waypoints; }; /** * Creates a flight plan from a route string, aircraft registration, and optional route options. * * This function parses the route string into waypoints, attaches weather data, finds an alternate aerodrome if not provided, * and constructs a flight plan with segments and performance calculations. * * @param planner - The PlannerService instance used for parsing and finding waypoints. * @param routeString - The route string to parse into waypoints. * @param aircraftRegistration - The registration of the aircraft to be used in the flight plan. * @param options - Optional parameters for the flight plan, including default altitude, departure date, and reserve fuel. * @returns A promise that resolves to a RouteTrip object representing the flight plan. */ export async function createFlightPlanFromString(weatherService, aerodromeService, aircrafService, routeString, aircraftRegistration, options = {}) { const waypoints = await parseRouteString(aerodromeService, routeString); const lastWaypoint = waypoints[waypoints.length - 1]; options.aircraft = await aircrafService.findByRegistration(aircraftRegistration); await weatherService.attachWeather(waypoints); // TODO: Improve this logic to find the alternate aerodrome // - Consider factors like runway length, instrument approaches, and services available. // - This might involve more complex lookups or integration with additional data sources. if (!options.alternate) { const alternateRadius = options.alternateRadius ?? 50; // Use option if provided, otherwise default to 50 const alternateExclude = lastWaypoint.ICAO ? [lastWaypoint.ICAO] : []; const alternate = await aerodromeService.nearest(lastWaypoint.location.geometry.coordinates, alternateRadius, alternateExclude); if (alternate) { await weatherService.attachWeather([alternate]); options.alternate = alternate; } } const segments = waypoints.map(waypoint => ({ waypoint, altitude: options.defaultAltitude })); segments[0].altitude = segments[0].waypoint.elevation; segments[segments.length - 1].altitude = segments[segments.length - 1].waypoint.elevation; const alternateSegment = options.alternate ? { waypoint: options.alternate, altitude: options.alternate.elevation } : undefined; const routeTrip = flightPlan(segments, alternateSegment, options.aircraft, options); const advisory = routeTripValidate(routeTrip, options.aircraft, options); return { ...routeTrip, advisory }; } /** * Generates a flight plan based on the provided route segments, alternate segment, aircraft, and options. * * This function calculates the route legs, total distance, duration, fuel consumption, and other performance metrics. * It returns a RouteTrip object containing all relevant information about the flight plan. * * @param segments - An array of RouteSegment objects representing the waypoints and altitudes for the flight. * @param alternateSegment - An optional RouteSegment representing an alternate aerodrome for the flight. * @param aircraft - The Aircraft object containing performance data for the flight. * @param options - Optional parameters for the flight plan, including departure date and reserve fuel duration. * @returns A RouteTrip object containing the calculated flight plan details. */ export function flightPlan(segments, alternateSegment, aircraft, options = {}) { if (segments.length < 2) { throw new Error('At least departure and arrival waypoints are required'); } const { departureDate = new Date(), reserveFuelDuration = 30, reserveFuel } = options; const legs = segments.slice(0, -1).map((startSegment, i) => calculateRouteLeg(startSegment, segments[i + 1], aircraft, departureDate)); let routeAlternate; if (alternateSegment) { routeAlternate = calculateRouteLeg(segments[segments.length - 1], alternateSegment, aircraft, departureDate); } 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 reserveFuelRequired = reserveFuel ?? (aircraft?.fuelConsumption ? aircraft.fuelConsumption * (reserveFuelDuration / 60) : 0); const totalTripFuel = totalFuelConsumption + (reserveFuelRequired || 0) + (options.takeoffFuel || 0) + (options.landingFuel || 0) + (options.taxiFuel || 0); const fuelBreakdown = { trip: totalFuelConsumption, reserve: reserveFuelRequired || 0, takeoff: options.takeoffFuel, landing: options.landingFuel, taxi: options.taxiFuel, alternate: routeAlternate?.performance?.fuelConsumption, }; 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} startSegment - The starting segment of the leg. * @param {RouteSegment} endSegment - The ending segment of the leg. * @param {Aircraft} [aircraft] - The aircraft used for performance calculations. * @param {Date} departureDate - The departure date from the start of this leg. * @returns {RouteLeg} The calculated route leg. */ function calculateRouteLeg(startSegment, endSegment, aircraft, departureDate) { const course = calculateRouteCourse(startSegment.waypoint, endSegment.waypoint); // TODO: // const temperature = startSegment.waypoint.metarStation?.metar.temperature; const wind = endSegment.waypoint.metarStation?.metar.wind; const performance = aircraft && wind && calculatePerformance(aircraft, course, wind); const arrivalDate = performance && new Date(departureDate.getTime() + performance.duration * 60 * 1000); return { start: startSegment, end: endSegment, course, wind, arrivalDate, performance, }; } /** * Calculates the course vector (distance and track) between two waypoints. * * @param {Waypoint} startWaypoint - The starting waypoint. * @param {Waypoint} endWaypoint - The ending waypoint. * @returns {CourseVector} The calculated course vector. */ function calculateRouteCourse(startWaypoint, endWaypoint) { const trueTrack = waypointHeading(startWaypoint, endWaypoint); const magneticDeclination = startWaypoint.declination || endWaypoint.declination || 0; return { distance: waypointDistance(startWaypoint, endWaypoint), track: bearingToAzimuth(trueTrack), magneticTrack: bearingToAzimuth(trueTrack - magneticDeclination), }; } /** * Calculates aircraft performance for a given course, aircraft, and wind conditions. * * @param {Aircraft} aircraft - The aircraft for which to calculate performance. * @param {CourseVector} course - The course vector (track and distance). * @param {Wind} wind - The wind conditions. * @returns {RouteLegPerformance | undefined} The calculated aircraft performance, or undefined if essential data is missing. */ function calculatePerformance(aircraft, course, wind) { if (!aircraft.cruiseSpeed) return undefined; // 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 = (course.distance / groundSpeed) * 60; 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 }; }