// Pure analytic flight math (v0.7.0). Position and fuel of an in-flight plane are // functions of the sim clock — no incremental stepping, so a tick can jump any // distance in game time without losing accuracy. All inputs are explicit; there // is no DB/IO here, which keeps this fully unit-testable with synthetic routes. import { weatherFor, type AirportWeather } from '@/lib/weather'; const toRad = (d: number) => (d * Math.PI) / 180; const toDeg = (r: number) => (r * 180) / Math.PI; // Flight duration in ms for a leg of distanceKm at speedKmh. export function travelDurationMs(distanceKm: number, speedKmh: number): number { if (!(speedKmh > 0)) throw new Error('speedKmh must be > 0'); return (distanceKm / speedKmh) * 3_600_000; } // --- Takeoff & landing phases (v1.12.0) --- // // Every leg has a 10-minute takeoff phase at the origin and a 10-minute landing phase at the // destination, on top of the pure cruise time. Weather at the relevant airport stretches each // phase up to 2× (bad conditions → longer rollout/approach); clear skies stay at 1×. The phases // burn no fuel — they are pure time, so the weighted-burn model is untouched. // One phase's duration in clear weather: 10 minutes of sim time. export const BASE_PHASE_MS = 600_000; // Weather → phase stretch factor, exactly [1, MAX_PHASE_FACTOR]. Simple monotone rule over the // condition severity order Clear < Cloudy < Rain < Fog — visibility in this generator is a // function of the condition (Fog ≤2 km, Rain 4–10 km, else 8–40 km), so it adds no independent // signal. Each step up from Clear adds one third of the headroom; Fog lands exactly on MAX_PHASE_FACTOR. export function phaseFactor(w: AirportWeather): number { const step = w.condition === 'Clear' ? 0 : w.condition === 'Cloudy' ? 1 : w.condition === 'Rain' ? 2 : 3; return 1 + step / 3; } // Upper end of phaseFactor over the weather domain — a Fog airport (the worst condition). export const MAX_PHASE_FACTOR = 2; export interface LegPhases { takeoffMs: number; // origin weather cruiseMs: number; // pure cruise — the old travelDurationMs value landingMs: number; // destination weather totalMs: number; // takeoff + cruise + landing } // The three timed segments of a leg. Takeoff scales on ORIGIN weather, landing on DESTINATION // weather; both are deterministic functions of the ICAOs (weatherFor), so any site holding the // two ICAs recomputes exactly what the engine used — no stored state needed. export function legPhases(distanceKm: number, speedKmh: number, originIcao: string, destIcao: string): LegPhases { const cruiseMs = travelDurationMs(distanceKm, speedKmh); const takeoffMs = BASE_PHASE_MS * phaseFactor(weatherFor(originIcao)); const landingMs = BASE_PHASE_MS * phaseFactor(weatherFor(destIcao)); return { takeoffMs, cruiseMs, landingMs, totalMs: takeoffMs + cruiseMs + landingMs }; } // Sim-clock time at which the leg completes — departure plus all three segments (v1.12.0). export function arrivalTimeFor( departureSimTime: number, distanceKm: number, speedKmh: number, originIcao: string, destIcao: string, ): number { return departureSimTime + legPhases(distanceKm, speedKmh, originIcao, destIcao).totalMs; } // CRUISE-segment progress in [0, 1] at a given sim-clock time (v1.12.0) — clamped to 0 before // departure and 1 after arrival: 0 while the plane holds at the origin during takeoff, linear // over the cruise window, 1 while it holds at the destination during landing. That keeps // positionAt/fuel pinned to each airport during its phase instead of crawling along the whole // route. A zero-distance leg has no cruise: complete once departed. export function progressAt( departureSimTime: number, distanceKm: number, speedKmh: number, originIcao: string, destIcao: string, clockMs: number, ): number { const ph = legPhases(distanceKm, speedKmh, originIcao, destIcao); const t = clockMs - departureSimTime; if (t <= 0) return 0; if (ph.cruiseMs <= 0) return 1; // zero-distance leg — nothing to cruise if (t >= ph.totalMs) return 1; if (t <= ph.takeoffMs) return 0; // holding at origin during takeoff if (t >= ph.takeoffMs + ph.cruiseMs) return 1; // holding at destination during landing return (t - ph.takeoffMs) / ph.cruiseMs; } export type LegPhase = 'takeoff' | 'cruise' | 'landing'; // Which timed segment a leg is in at a given sim-clock time — for marker display (v1.12.0). export function legPhaseAt( departureSimTime: number, distanceKm: number, speedKmh: number, originIcao: string, destIcao: string, clockMs: number, ): LegPhase { const ph = legPhases(distanceKm, speedKmh, originIcao, destIcao); const t = clockMs - departureSimTime; if (t < ph.takeoffMs) return 'takeoff'; if (t < ph.takeoffMs + ph.cruiseMs) return 'cruise'; return 'landing'; } // Great-circle interpolation between two WGS84 points at progress t ∈ [0,1] // (slerp on the unit sphere). Exact endpoints: t=0 → from, t=1 → to. export function positionAt( fromLat: number, fromLon: number, toLat: number, toLon: number, t: number, ): { lat: number; lon: number } { if (t <= 0) return { lat: fromLat, lon: fromLon }; if (t >= 1) return { lat: toLat, lon: toLon }; const vec = (lat: number, lon: number): [number, number, number] => { const phi = toRad(lat); const lambda = toRad(lon); return [Math.cos(phi) * Math.cos(lambda), Math.cos(phi) * Math.sin(lambda), Math.sin(phi)]; }; const a = vec(fromLat, fromLon); const b = vec(toLat, toLon); const dot = Math.max(-1, Math.min(1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2])); const theta = Math.acos(dot); if (theta < 1e-9) return { lat: fromLat, lon: fromLon }; // same point const sinTheta = Math.sin(theta); const wA = Math.sin((1 - t) * theta) / sinTheta; const wB = Math.sin(t * theta) / sinTheta; const v: [number, number, number] = [a[0] * wA + b[0] * wB, a[1] * wA + b[1] * wB, a[2] * wA + b[2] * wB]; return { lat: toDeg(Math.asin(Math.max(-1, Math.min(1, v[2])))), lon: toDeg(Math.atan2(v[1], v[0])), }; } // --- Weight-proportional fuel burn (v0.16.0) --- // // The instantaneous burn rate is proportional to total weight: r(F) = k·(We + ρF) L/km, // where We is the empty weight in kg, ρ the fuel density and F the current load in // liters — a heavier plane burns more per km. k is calibrated so that a FULL tank // burns exactly the catalog's constant burnPerKm: k = burnPerKm / (We + ρ·maxFuel), // so full-tank legs match the old model at departure and only diverge as fuel (and // weight) drops off in flight. Closed forms of dF/dx = −k(We + ρF): // fuel after distance D from F₀: (F₀ + We/ρ)·e^{−kρD} − We/ρ // consumption over D: (F₀ + We/ρ)·(1 − e^{−kρD}) // fuel required to cover D arriving with 0, independent of F₀: (We/ρ)(e^{kρD} − 1) // Jet-A1 density in kg/L — converts a liter load into its weight contribution. export const FUEL_DENSITY_KG_PER_L = 0.8; // Calibration constant k for one plane type (see the model note above). export function burnRateConstant(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number { return burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel); } // Fuel remaining after flying `progress` of a leg of distanceKm from startFuel under the // weighted model. Clamped at 0 — range exhaustion is not modeled (a plane that runs dry // limps to the gate on fumes, same as before). export function fuelAtWeighted( startFuel: number, burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number, progress: number, ): number { const d = distanceKm * progress; if (d <= 0) return startFuel; const k = burnRateConstant(burnPerKm, emptyWeightKg, maxFuel); const rho = FUEL_DENSITY_KG_PER_L; const f = (startFuel + emptyWeightKg / rho) * Math.exp(-k * rho * d) - emptyWeightKg / rho; return Math.max(0, f); } // Total fuel required to cover distanceKm arriving with an empty tank — independent of the // starting load. A leg is flyable from a plane iff its current fuel covers this amount; it // fits in the aircraft at all iff this does not exceed the tank capacity. export function requiredFuelFor( burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number, ): number { if (distanceKm <= 0) return 0; const k = burnRateConstant(burnPerKm, emptyWeightKg, maxFuel); const rho = FUEL_DENSITY_KG_PER_L; return (emptyWeightKg / rho) * (Math.exp(k * rho * distanceKm) - 1); } // Wind adjustment to per-km burn (v0.36.0, ISS-070): the headwind component of a wind blowing // FROM `windDirDeg` against a leg heading `bearingDeg`, as a multiplier on burnPerKm — // factor = 1 + headwindKt/150, exactly [0.8, 1.2] over the weather range 2–30 kt (±20%). // Pure; callers apply it as burnPerKm * factor so every call site agrees on one value per leg. export function windBurnFactor(windKt: number, windDirDeg: number, bearingDeg: number): number { const headwindKt = windKt * Math.cos(((bearingDeg - windDirDeg) * Math.PI) / 180); return 1 + headwindKt / 150; } // Max range from a FULL tank under the weighted model — solve requiredFuelFor(d) = maxFuel for d: // (We/ρ)(e^{kρd} − 1) = F → d = ln(1 + ρF/We) · (We + ρF)/(ρ·burnPerKm). // Returns raw km (call sites round); zero fuel gives exactly 0. Pure — display-only, the sim // itself does not model range exhaustion (see fuelAtWeighted). export function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number { if (!(emptyWeightKg > 0)) throw new Error('emptyWeightKg must be > 0'); const rho = FUEL_DENSITY_KG_PER_L; return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm)); } // Max headwind burn multiplier over the weather domain (v0.51.0, ISS-099): the upper end of // windBurnFactor's [0.8, 1.2] range — a full 30 kt headwind (the max in lib/weather.ts) ÷ 150. export const MAX_WIND_BURN_FACTOR = 1 + 30 / 150; // Guaranteed full-tank range (v0.51.0, ISS-099): the maxRangeKm closed form under worst-case // headwind burn — any leg within this distance is flyable in ALL weather, since windBurnFactor // never exceeds MAX_WIND_BURN_FACTOR and requiredFuelFor grows with the burn rate. This is what // the UI displays as "Max range", so a spec-possible leg always passes flyPlane's fuel guard // (requiredFuelFor ≤ maxFuel under the same wind-adjusted burn). export function guaranteedRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number { return maxRangeKm(burnPerKm * MAX_WIND_BURN_FACTOR, emptyWeightKg, maxFuel); }