UNPKG

flight-planner

Version:

Plan and route VFR flights

392 lines (391 loc) 17 kB
import { Aerodrome, VisualReportingPoint, Waypoint } from './waypoint.js'; import { calculateGroundspeed, calculateWindCorrectionAngle, calculateWindVector, isICAO, normalizeTrack } from './utils.js'; import { point } 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 = ((track % 360) + 360) % 360; 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); }; /** * FlightPlanner class to handle flight route planning operations */ class FlightPlanner { weatherService; aerodromeService; /** * Creates a new FlightPlanner instance * * @param weatherService - Weather service for retrieving weather data along the flight route * Used to get wind information and other meteorological conditions * @param aerodromeService - Aerodrome service for fetching airport and airfield data * Used to look up airports by ICAO code and retrieve their information */ constructor(weatherService, aerodromeService) { this.weatherService = weatherService; this.aerodromeService = aerodromeService; } /** * Helper method to calculate fuel consumption based on aircraft and duration * * @param aircraft - The aircraft for which to calculate fuel consumption * @param duration - Flight duration in minutes * @returns The fuel consumption in gallons/liters or undefined if not available */ calculateFuelConsumption(aircraft, duration) { return aircraft.fuelConsumption ? aircraft.fuelConsumption * (duration / 60) : undefined; } /** * Attaches relevant weather data to waypoints by fetching METAR information * First tries to get data for aerodromes by ICAO code, then finds nearest stations for other waypoints * * @param waypoints - The waypoints to attach weather data to * @throws Will not throw but logs errors encountered during the process */ async attachWeatherData(waypoints) { await Promise.all(waypoints .filter(waypoint => FlightPlanner.isAerodrome(waypoint)) .map(async (aerodrome) => { try { const stations = await this.weatherService.get(aerodrome.ICAO); if (stations?.length) { aerodrome.metarStation = stations[0]; } } catch (error) { console.warn(`Failed to fetch weather for aerodrome ${aerodrome.ICAO}:`, error); } })); await Promise.all(waypoints .filter(waypoint => !waypoint.metarStation) .map(async (waypoint) => { try { const station = await this.weatherService.nearest(waypoint.location.geometry.coordinates); if (station) { waypoint.metarStation = station; } } catch (error) { console.warn(`Failed to fetch nearest weather station for waypoint ${waypoint.name}:`, error); } })); } /** * Creates a flight plan from a route string, which can include ICAO codes, reporting points, and waypoints. * * @param routeString - A string representing the route, e.g., "EDDF;RP(ALPHA);WP(50.05,8.57)" * @param options - Optional configuration options for the flight route * @returns A route trip object with legs, distances, durations, and fuel calculations * @throws Error if no valid waypoints could be parsed from the route string */ async createFlightPlanFromString(routeString, options = {}) { const waypoints = await this.parseRouteString(routeString); if (waypoints.length === 0) { throw new Error('No valid waypoints could be parsed from the route string'); } return this.createFlightPlan(waypoints.map(waypoint => ({ waypoint })), options); } /** * Creates a flight plan based on an array of waypoints and optional route options. * * @param segments - An array of route segments, each containing a waypoint and optional altitude * @param options - Optional configuration options for the flight route * @returns A route trip object with legs, distances, durations, and fuel calculations * @throws Error if fewer than 2 waypoints are provided */ async createFlightPlan(segments, options = {}) { if (segments.length < 2) { throw new Error('At least departure and arrival waypoints are required'); } const { defaultAltitude, departureDate = new Date(), aircraft, reserveFuelDuration = 30, reserveFuel, } = options; await this.attachWeatherData(segments.map(segment => segment.waypoint)); const legs = segments.slice(0, -1).map((startSegment, i) => { const endSegment = segments[i + 1]; if (!startSegment.altitude) { startSegment.altitude = defaultAltitude; } if (!endSegment.altitude) { endSegment.altitude = defaultAltitude; } // TODO: Wrap these in course vectors const distance = startSegment.waypoint.distance(endSegment.waypoint); const track = normalizeTrack(startSegment.waypoint.heading(endSegment.waypoint)); const altitude = startSegment.altitude; const course = { distance, track, altitude, }; // TODO: // const temperature = startSegment.waypoint.metarStation?.metar.temperature; const wind = startSegment.waypoint.metarStation?.metar.wind; const performance = aircraft && wind ? this.calculatePerformance(aircraft, course, wind) : undefined; const arrivalDate = performance ? new Date(departureDate.getTime() + performance.duration * 60 * 1000) : undefined; return { start: startSegment, end: endSegment, course, wind, arrivalDate, performance, }; }); 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; } // Calculate total fuel required const reserveFuelRequired = reserveFuel ?? (aircraft ? this.calculateFuelConsumption(aircraft, reserveFuelDuration) : 0); const totalFuelRequired = totalFuelConsumption + (reserveFuelRequired || 0) + (options.takeoffFuel || 0) + (options.landingFuel || 0) + (options.taxiFuel || 0); const arrivalDate = new Date(departureDate.getTime() + totalDuration * 60 * 1000); return { route: legs, aircraft, totalDistance, totalDuration, totalFuelConsumption, totalFuelRequired, departureDate, arrivalDate, }; } /** * Calculates the performance of the aircraft based on wind and course vector. * * @param aircraft - The aircraft for which to calculate performance * @param course - The course vector containing distance and track * @param wind - The wind conditions affecting the flight * @returns An object containing performance metrics or undefined if not applicable */ calculatePerformance(aircraft, course, wind) { if (aircraft.cruiseSpeed) { const windVector = calculateWindVector(wind, course.track); const wca = calculateWindCorrectionAngle(wind, course.track, aircraft.cruiseSpeed); const heading = normalizeTrack(course.track + wca); // TODO: Correct for magnetic variation const groundSpeed = calculateGroundspeed(wind, aircraft.cruiseSpeed, heading); const duration = (course.distance / groundSpeed) * 60; const fuelConsumption = this.calculateFuelConsumption(aircraft, duration); return { headWind: windVector.headwind, crossWind: windVector.crosswind, trueAirSpeed: aircraft.cruiseSpeed, // TODO: Correct for altitude, temperature windCorrectionAngle: wca, heading, groundSpeed, duration, fuelConsumption }; } return undefined; } /** * 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 */ static getRouteWaypoints(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 */ static getDepartureWaypoint(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 */ static getArrivalWaypoint(routeTrip) { return routeTrip.route[routeTrip.route.length - 1].end.waypoint; } /** * Tests if a given waypoint is an Aerodrome. * * @param waypoint - The waypoint to test * @returns True if the waypoint is an Aerodrome, false otherwise */ static isAerodrome(waypoint) { return waypoint instanceof Aerodrome; } /** * Tests if a given waypoint is a ReportingPoint. * * @param waypoint - The waypoint to test * @returns True if the waypoint is a ReportingPoint, false otherwise */ static isReportingPoint(waypoint) { return waypoint instanceof VisualReportingPoint; } /** * Tests if a given waypoint is a basic Waypoint. * * @param waypoint - The waypoint to test * @returns True if the waypoint is a basic Waypoint, false otherwise */ static isWaypoint(waypoint) { return waypoint instanceof Waypoint && !(waypoint instanceof Aerodrome) && !(waypoint instanceof VisualReportingPoint); } /** * Parses a route string and returns an array of Aerodrome or Waypoint objects. * * @param routeString - The route string to parse * Supported formats: * - ICAO codes (e.g., "EDDF") * - RP(name) for reporting points (e.g., "RP(ALPHA)") * - WP(lat,lng) for waypoints (e.g., "WP(50.05,8.57)") * @returns A promise that resolves to an array of Aerodrome, ReportingPoint, or Waypoint objects * @throws Error if the route string contains invalid waypoint formats */ async parseRouteString(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 reportingPointRegex = /^RP\(([A-Z0-9_]+)\)$/; const parseErrors = []; for (const part of routeParts) { try { // Check for ICAO code if (isICAO(part)) { const airport = await this.aerodromeService.get(part); if (airport?.length) { waypoints.push(...airport); continue; } else { throw new Error(`Could not find aerodrome with ICAO code: ${part}`); } } // TODO: Check for things like NAVAIDs, VORs, NDBs, etc. // TOOD: Check for VFR waypoints, starting with VRP_XX // const rpMatch = part.match(reportingPointRegex); // if (rpMatch) { // const name = rpMatch[1]; // const rp = new ReportingPoint(name, point([0, 0])); // Coordinates would need to be looked up // waypoints.push(rp); // continue; // } 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}`); } waypoints.push(new Waypoint(`WP-${lat.toFixed(2)},${lng.toFixed(2)}`, point([lng, lat]))); 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; } /** * Converts a route trip to a string representation. * * @param routeTrip - The route trip to convert * @returns A string representation of the route trip */ static toRouteString(routeTrip) { return FlightPlanner.getRouteWaypoints(routeTrip).map(waypoint => { if (FlightPlanner.isAerodrome(waypoint)) { return waypoint.ICAO; } else if (FlightPlanner.isReportingPoint(waypoint)) { return `RP(${waypoint.name})`; } else if (FlightPlanner.isWaypoint(waypoint)) { const coords = waypoint.location.geometry.coordinates; return `WP(${coords[1].toFixed(5)},${coords[0].toFixed(5)})`; } return ''; }).join(';'); } } export default FlightPlanner;