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

258 lines
11 KiB
TypeScript
Raw Permalink 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.
import { describe, expect, it } from 'vitest';
import { haversineKm } from '@/lib/geo';
import {
arrivalTimeFor,
burnRateConstant,
FUEL_DENSITY_KG_PER_L,
fuelAtWeighted,
guaranteedRangeKm,
MAX_WIND_BURN_FACTOR,
maxRangeKm,
positionAt,
progressAt,
requiredFuelFor,
travelDurationMs,
windBurnFactor,
} from './movement';
// Synthetic in-flight fixtures — no DB needed: everything is a pure function of
// the sim clock. ATR 72-600 numbers from lib/game/catalog.ts.
const DEP = 1_750_000_000_000; // departure sim time (ms)
const SPEED = 510; // km/h
const BURN = 1.75; // L/km at a full tank
const WE = 9_300; // kg, operating empty weight
const TANK = 6_300; // liters, full tank
// Frankfurt → Madrid, real coordinates from the CSV dataset.
const FROM = { lat: 50.0333, lon: 8.5706 };
const TO = { lat: 40.47, lon: -3.56 };
const KM = haversineKm(FROM.lat, FROM.lon, TO.lat, TO.lon); // ≈ 2190 km
describe('travelDurationMs / arrivalTimeFor', () => {
it('duration is distance/speed converted to ms', () => {
expect(travelDurationMs(510, SPEED)).toBe(3_600_000); // one hour at cruise
expect(travelDurationMs(KM, SPEED)).toBeCloseTo((KM / SPEED) * 3_600_000, 9);
});
it('rejects a non-positive speed', () => {
expect(() => travelDurationMs(100, 0)).toThrow();
});
it('arrival is departure + duration', () => {
const arrival = arrivalTimeFor(DEP, KM, SPEED);
expect(arrival).toBeCloseTo(DEP + (KM / SPEED) * 3_600_000, 9);
expect(arrival).toBeGreaterThan(DEP);
});
});
describe('progressAt', () => {
const duration = travelDurationMs(KM, SPEED);
it('is 0 before departure and at departure', () => {
expect(progressAt(DEP, KM, SPEED, DEP - 1)).toBe(0);
expect(progressAt(DEP, KM, SPEED, DEP)).toBe(0);
});
it('is linear in the middle of the flight', () => {
// Precision 9: clock values sit at ~1.75e12 ms, so (clock - DEP) loses a few
// ulps to cancellation — 1e-9 progress is still sub-millisecond on this leg.
expect(progressAt(DEP, KM, SPEED, DEP + duration / 2)).toBeCloseTo(0.5, 9);
expect(progressAt(DEP, KM, SPEED, DEP + duration * 0.25)).toBeCloseTo(0.25, 9);
});
it('is exactly 1 at arrival and stays 1 after', () => {
const arrival = DEP + duration;
expect(progressAt(DEP, KM, SPEED, arrival)).toBe(1);
expect(progressAt(DEP, KM, SPEED, arrival + 10_000)).toBe(1);
});
it('a zero-distance leg is complete immediately', () => {
expect(progressAt(DEP, 0, SPEED, DEP - 5)).toBe(1);
});
});
describe('positionAt (great-circle slerp)', () => {
it('returns the exact endpoints at progress 0 and 1', () => {
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 0)).toEqual({ lat: FROM.lat, lon: FROM.lon });
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 1)).toEqual({ lat: TO.lat, lon: TO.lon });
});
it('clamps out-of-range progress to the endpoints', () => {
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, -0.5)).toEqual({ lat: FROM.lat, lon: FROM.lon });
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 1.5)).toEqual({ lat: TO.lat, lon: TO.lon });
});
it('stays on the great circle: arc lengths add up to the total distance', () => {
for (const t of [0.1, 0.37, 0.5, 0.9]) {
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, t);
const d1 = haversineKm(FROM.lat, FROM.lon, p.lat, p.lon);
const d2 = haversineKm(p.lat, p.lon, TO.lat, TO.lon);
expect(d1 + d2).toBeCloseTo(KM, 6); // slerp keeps the point on the arc
}
});
it('the midpoint is equidistant from both ends', () => {
const m = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 0.5);
expect(haversineKm(FROM.lat, FROM.lon, m.lat, m.lon)).toBeCloseTo(KM / 2, 6);
expect(haversineKm(m.lat, m.lon, TO.lat, TO.lon)).toBeCloseTo(KM / 2, 6);
});
it('is exact along the equator (great circle = constant latitude)', () => {
for (const t of [0.25, 0.5, 0.75]) {
const p = positionAt(0, -10, 0, 10, t);
expect(p.lat).toBeCloseTo(0, 9);
expect(p.lon).toBeCloseTo(-10 + 20 * t, 9);
}
});
it('handles the same-point route without dividing by zero', () => {
const p = positionAt(48.85, 2.35, 48.85, 2.35, 0.5);
expect(p).toEqual({ lat: 48.85, lon: 2.35 });
});
});
describe('weighted burn model (v0.16.0)', () => {
it('calibrates so a full tank burns exactly the old constant rate', () => {
const k = burnRateConstant(BURN, WE, TANK);
expect(k * (WE + FUEL_DENSITY_KG_PER_L * TANK)).toBeCloseTo(BURN, 12);
// Over a short hop from full, weighted ≈ constant — divergence only grows as fuel drops off.
const d = 100;
const consumed = TANK - fuelAtWeighted(TANK, BURN, WE, TANK, d, 1);
expect(Math.abs(consumed - BURN * d)).toBeLessThan(1); // < 1 L over 100 km from full
});
it('consumption matches numeric integration of r(F) = k·(We + ρF)', () => {
const k = burnRateConstant(BURN, WE, TANK);
const rho = FUEL_DENSITY_KG_PER_L;
// Euler-integrate dF/dx = −k(We + ρF) from a full tank over the whole leg.
const steps = 200_000;
const h = KM / steps;
let f = TANK;
for (let i = 0; i < steps; i++) f -= k * (WE + rho * f) * h;
expect(Math.abs(fuelAtWeighted(TANK, BURN, WE, TANK, KM, 1) - f)).toBeLessThan(0.001); // Euler drift is sub-milliliter here
});
it('requiredFuelFor is zero for a zero leg and strictly increasing in distance', () => {
expect(requiredFuelFor(BURN, WE, TANK, 0)).toBe(0);
let prev = requiredFuelFor(BURN, WE, TANK, 100);
for (const d of [500, 1_000, 2_000, 3_000, 4_000]) {
const cur = requiredFuelFor(BURN, WE, TANK, d);
expect(cur).toBeGreaterThan(prev);
prev = cur;
}
});
it('a lighter start burns less over the same distance', () => {
const burnFromFull = TANK - fuelAtWeighted(TANK, BURN, WE, TANK, KM, 1);
const burnFromHalf = TANK / 2 - fuelAtWeighted(TANK / 2, BURN, WE, TANK, KM, 1);
expect(burnFromFull).toBeGreaterThan(burnFromHalf);
});
it('starting with exactly the required fuel arrives on empty', () => {
const req = requiredFuelFor(BURN, WE, TANK, KM);
expect(fuelAtWeighted(req, BURN, WE, TANK, KM, 1)).toBeCloseTo(0, 6);
});
it('clamps at zero instead of going negative', () => {
expect(fuelAtWeighted(100, BURN, WE, TANK, KM, 1)).toBe(0); // needs ~2800 L from full, has 100
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, 0)).toBe(TANK);
expect(fuelAtWeighted(TANK, BURN, WE, TANK, 0, 1)).toBe(TANK);
});
it('composes with progressAt for a just-before-arrival snapshot', () => {
const duration = travelDurationMs(KM, SPEED);
const clock = DEP + duration - 1; // one ms before arrival
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, clock));
expect(haversineKm(p.lat, p.lon, TO.lat, TO.lon)).toBeGreaterThan(0); // not yet at the gate
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(0);
});
});
describe('windBurnFactor (v0.36.0, ISS-070)', () => {
it('full headwind at max wind is exactly +20%', () => {
// bearing == windDirDeg: the wind blows straight from ahead.
expect(windBurnFactor(30, 90, 90)).toBe(1.2);
});
it('full tailwind at max wind is exactly −20%', () => {
// windDirDeg = bearing + 180 (mod 360): the wind blows from behind.
expect(windBurnFactor(30, 270, 90)).toBe(0.8);
});
it('a pure crosswind (90° off) leaves burn unchanged', () => {
expect(windBurnFactor(30, 0, 90)).toBeCloseTo(1, 9);
expect(windBurnFactor(30, 180, 90)).toBeCloseTo(1, 9);
});
it('small wind stays near 1', () => {
const f = windBurnFactor(2, 45, 45); // worst case: full headwind at min wind
expect(f).toBeGreaterThan(1.01);
expect(f).toBeLessThan(1.02);
});
it('stays within [0.8, 1.2] for the whole weather range', () => {
for (const kt of [2, 15, 30]) {
for (const off of [0, 45, 90, 135, 180, 270, 315]) {
const f = windBurnFactor(kt, 100, (100 + off) % 360);
expect(f).toBeGreaterThanOrEqual(0.8 - 1e-12);
expect(f).toBeLessThanOrEqual(1.2 + 1e-12);
}
}
});
it('applied as burnPerKm × f, requiredFuelFor and fuelAtWeighted scale accordingly (pinned)', () => {
// Fixed inputs: ATR numbers, 1000 km leg / half of the FRA→MAD leg.
expect(requiredFuelFor(BURN * 1.2, WE, TANK, 1_000)).toBeCloseTo(1444.911589, 6);
expect(requiredFuelFor(BURN * 0.8, WE, TANK, 1_000)).toBeCloseTo(944.348095, 6);
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 0.5)).toBeCloseTo(4867.313136, 6);
expect(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 0.5)).toBeCloseTo(5331.677856, 6);
// Headwind always costs more than tailwind over the same leg…
expect(requiredFuelFor(BURN * 1.2, WE, TANK, KM)).toBeGreaterThan(requiredFuelFor(BURN * 0.8, WE, TANK, KM));
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 1)).toBeLessThan(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 1));
// …and a neutral factor (f = 1) reproduces the unadjusted model exactly.
expect(requiredFuelFor(BURN * windBurnFactor(30, 0, 90), WE, TANK, KM)).toBeCloseTo(requiredFuelFor(BURN, WE, TANK, KM), 9);
});
});
describe('maxRangeKm (v0.28.0)', () => {
it('ATR 72-600 full tank ≈ 4439 km (±5%)', () => {
const r = maxRangeKm(BURN, WE, TANK);
expect(r).toBeGreaterThan(4439 * 0.95);
expect(r).toBeLessThan(4439 * 1.05);
});
it('increases with tank size and decreases with burn rate', () => {
expect(maxRangeKm(BURN, WE, TANK * 2)).toBeGreaterThan(maxRangeKm(BURN, WE, TANK));
expect(maxRangeKm(3, WE, TANK)).toBeLessThan(maxRangeKm(BURN, WE, TANK));
});
it('zero fuel → zero range', () => {
expect(maxRangeKm(BURN, WE, 0)).toBe(0);
});
it('rejects a non-positive empty weight', () => {
expect(() => maxRangeKm(BURN, 0, TANK)).toThrow();
});
});
describe('guaranteedRangeKm (v0.51.0, ISS-099)', () => {
it("MAX_WIND_BURN_FACTOR is windBurnFactor's actual maximum over the weather domain", () => {
// Full headwind at max wind: exactly +20% — the constant must track the factor function, not a copy.
expect(MAX_WIND_BURN_FACTOR).toBe(windBurnFactor(30, 90, 90));
});
it('is maxRangeKm under worst-case headwind burn (ATR ≈ 3696 km ±5%)', () => {
const r = guaranteedRangeKm(BURN, WE, TANK);
expect(r).toBeCloseTo(maxRangeKm(BURN * MAX_WIND_BURN_FACTOR, WE, TANK), 12);
expect(r).toBeGreaterThan(3696 * 0.95);
expect(r).toBeLessThan(3696 * 1.05);
});
it('zero fuel → zero range; rejects a non-positive empty weight', () => {
expect(guaranteedRangeKm(BURN, WE, 0)).toBe(0);
expect(() => guaranteedRangeKm(BURN, 0, TANK)).toThrow();
});
});