140 lines
6.2 KiB
TypeScript
140 lines
6.2 KiB
TypeScript
// 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));
|
||
}
|