// 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. 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; } // Sim-clock time at which the leg completes. export function arrivalTimeFor(departureSimTime: number, distanceKm: number, speedKmh: number): number { return departureSimTime + travelDurationMs(distanceKm, speedKmh); } // Flight progress in [0, 1] at a given sim-clock time — clamped to 0 before // departure and 1 after arrival. A zero-distance leg is complete immediately. export function progressAt( departureSimTime: number, distanceKm: number, speedKmh: number, clockMs: number, ): number { const duration = travelDurationMs(distanceKm, speedKmh); if (duration <= 0) return 1; return Math.min(1, Math.max(0, (clockMs - departureSimTime) / duration)); } // 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); }