Files
Flight-Ops/lib/sim/movement.ts
T
2026-09-22 08:57:09 +00:00

153 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}