Files
flight-sim/tests/physics.test.mjs
T
Quintine 8c19cdaae6 3D flight simulator: 6-DOF physics, three.js r160, browser HUD
- Detailed flight dynamics: aero lift/drag/side-force with smooth stall,
  control-surface moments, stability derivatives, engine spool with prop
  torque and gyro precession, inertial coupling, ISA atmosphere, ground
  effect, 3-point gear handling, landing vs crash classification.
- Pure ESM, zero build step; three.js vendored locally. Deterministic
  simulation; physics core is Node-testable (14 node:test cases).
- Procedural 8 km world with runway, water, sky dome, cloud wrap; DOM
  instrument panel (attitude indicator, tapes, VSI, lamps, stall warning).
- Controls: WASD/arrows, Q/E rudder, Shift/Ctrl throttle, F flaps, G gear,
  B brakes, C camera (chase/cockpit/orbit), R reset, P pause, D debug.
2026-08-24 17:58:58 +10:00

451 lines
19 KiB
JavaScript
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.
// tests/physics.test.mjs — deterministic unit tests for src/physics.js.
// Run: node --test tests/physics.test.mjs
//
// Covers the 7 required SPEC tests plus brutallly-honest extras:
// instrument completeness/ranges, 3000 m / 80% cruise envelope, rudder turn
// direction consistency, gear-up vs gear-down drag, monotonic fuel, and
// exact determinism across substep splits.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createAircraft, step, resetAircraft,
getInstrumentData, getAeroSummary,
} from '../src/physics.js';
import { cfg, atmoDensity } from '../src/config.js';
import { getGroundHeight, WORLD } from '../src/world.js';
const DT = 1 / 120; // caller-guaranteed fixed step (main.js FIXED_DT)
const KTS = 1.94384;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Quaternion [w,x,y,z]: level-north (heading 0, wings level) then pitch up. */
export function levelNoseUpQuat(pitchRad) {
const qp = Math.cos(pitchRad / 2), sp = Math.sin(pitchRad / 2);
const LQ = [0.5, 0.5, 0.5, -0.5]; // level-north (validated vs three.js r160)
return [
qp * LQ[0] - sp * LQ[1],
qp * LQ[1] + sp * LQ[0],
qp * LQ[2] + sp * LQ[3],
qp * LQ[3] - sp * LQ[2],
];
}
/**
* Build a steady level-cruise state at `altM`: lift = weight, thrust = drag,
* zero pitching moment, zero rates (analytic trim). Returns the aircraft, the
* inputs snapshot that holds the trim, and the design cruise speed Vt.
*/
export function cruiseState(altM, throttle = 0.7) {
const rho = atmoDensity(altM);
const W = cfg.mass * cfg.g;
const alphaT = 0.010; // rad trim alpha (small positive AOA)
const CLt = cfg.CL0 + cfg.CLalpha * alphaT;
const Vt = Math.sqrt((2 * W) / (rho * cfg.S * CLt)); // L = W
const CDt = cfg.CD0 + cfg.kInd * CLt * CLt;
const Dt = 0.5 * rho * Vt * Vt * cfg.S * CDt;
const rpmNeeded = Dt / (cfg.Tmax * Math.pow(rho / 1.225, 0.7));
const rpm = Math.max(throttle, rpmNeeded); // keep the engine spooled at set point
const deT = -(cfg.Cm0 + cfg.CmAlpha * alphaT) / cfg.Cmδe; // Cm = 0 at q̇=0
const elevT = -deT / cfg.dE_max; // δe = −elev·dE_max (input is the stick)
assert.ok(rpm <= 1, `cruise rpm ${rpm} must not exceed 1`);
const a = createAircraft();
a.pos = [0, altM, 0];
a.quat = levelNoseUpQuat(alphaT);
// Level flight at pitch αt: body velocity has downward-body component +V·sinαt.
a.vel = [Vt * Math.cos(alphaT), 0, Vt * Math.sin(alphaT)];
a.rpm = rpm;
a.throttle = rpm;
a.flaps = 0;
a.gearDown = false;
a.brakes = false;
const inputs = {
elevator: elevT, aileron: 0, rudder: 0, throttle: rpm,
flaps: 0, gear: false, brakes: false,
};
return { a, inputs, Vt };
}
function run(a, inputs, seconds, dt = DT) {
const n = Math.round(seconds / dt);
for (let i = 0; i < n; i++) step(a, inputs, dt);
}
/** Unwrapped heading change (deg) over the run, monitoring each step. */
function headingDrift(a, inputs, seconds) {
let unwrap = 0;
let prev = getInstrumentData(a).headingDeg;
for (let s = 0; s < Math.round(seconds); s++) {
run(a, inputs, 1);
const h = getInstrumentData(a).headingDeg;
unwrap += (h - prev + 540) % 360 - 180;
prev = h;
}
return unwrap;
}
// ---------------------------------------------------------------------------
// 1. Trim / cruise: 1000 m, throttle 0.7, 20 s → airspeed within ±5% of the
// analytic design-cruise speed, and inside 120–180 kts.
// ---------------------------------------------------------------------------
test('trim/cruise holds design airspeed at 1000 m (70% throttle)', () => {
const { a, inputs, Vt } = cruiseState(1000, 0.7);
assert.ok(Vt * KTS >= 120 && Vt * KTS <= 180,
`design cruise ${(Vt * KTS).toFixed(0)} kts must be in 120-180 kts`);
run(a, inputs, 20);
const d = getInstrumentData(a);
assert.ok(Math.abs(d.airspeedMps - Vt) / Vt <= 0.05,
`airspeed ${d.airspeedMps.toFixed(1)} m/s vs cruise ${Vt.toFixed(1)} ` +
`(${(Math.abs(d.airspeedMps - Vt) / Vt * 100).toFixed(1)}%)`);
assert.ok(d.airspeedKts >= 120 && d.airspeedKts <= 180,
`airspeed ${d.airspeedKts.toFixed(0)} kts must be within 120-180 kts`);
// altitude should stay near the cruise level (no uncontrolled dive/climb)
assert.ok(Math.abs(d.altM - 1000) < 150, `altitude ${d.altM.toFixed(0)} m drifted`);
});
// ---------------------------------------------------------------------------
// 2. Stall: full up elevator, 30% throttle → α > α_crit within 25 s, stall
// flag true.
// ---------------------------------------------------------------------------
test('full up elevator at 30% throttle stalls within 25 s', () => {
const { a, inputs } = cruiseState(1000, 0.7);
const stall = { elevator: 1, aileron: 0, rudder: 0, throttle: 0.3,
flaps: 0, gear: false, brakes: false };
let sawStall = false;
let maxAlpha = 0;
for (let s = 0; s < 25; s++) {
run(a, stall, 1);
const d = getInstrumentData(a);
maxAlpha = Math.max(maxAlpha, Math.abs(d.alphaDeg));
if (d.stall) sawStall = true;
}
assert.ok(maxAlpha > cfg.alphaCrit * (180 / Math.PI),
`alpha never exceeded alpha_crit (max |alpha| ${maxAlpha.toFixed(1)}°)`);
assert.ok(sawStall, 'stall flag never became true within 25 s');
});
// ---------------------------------------------------------------------------
// 3. Inverted sign: at α = −10° the lift coefficient (and lift force) is
// negative — lift points down.
// ---------------------------------------------------------------------------
test('inverted sign: CL < 0 at alpha = -10 deg (lift points down)', () => {
const a = createAircraft();
a.pos = [0, 1000, 0];
a.flaps = 0;
a.gearDown = false;
const V = 60;
const aN = -10 * Math.PI / 180;
a.vel = [V * Math.cos(aN), 0, V * Math.sin(aN)]; // vz > 0 → alpha = −10°
const s = getAeroSummary(a);
assert.ok(Math.abs(s.alpha - aN) < 1e-9, `alpha ${s.alpha} != -10 deg`);
assert.ok(s.cl < 0, `cl ${s.cl.toFixed(3)} must be negative at alpha=-10`);
assert.ok(s.liftN < 0, `liftN ${s.liftN.toFixed(0)} must point down`);
// mirror check: at +10° the sign flips
a.vel = [V * Math.cos(-aN), 0, V * Math.sin(-aN)];
const s2 = getAeroSummary(a);
assert.ok(s2.cl > 0 && s2.liftN > 0, 'lift must be positive at alpha=+10');
});
// ---------------------------------------------------------------------------
// 4. Ground stop: spawn on runway, gear down, brakes → rest in < 400 m and
// never sink below ground − 0.1.
// ---------------------------------------------------------------------------
test('braked ground stop from 30 m/s < 400 m, never below ground', () => {
const a = createAircraft(); // spawns on the runway, gear down, brakes on
a.rpm = 0; // dead-stick: brakes alone must stop the airframe
a.engineOn = false;
a.vel = [30, 0, 0]; // north-bound (body +X at heading 0 = world −Z)
const inputs = { elevator: 0, aileron: 0, rudder: 0, throttle: 0,
flaps: 0.25, gear: true, brakes: true };
const groundAt = getGroundHeight(a.pos[0], a.pos[2]);
const start = [a.pos[0], a.pos[2]];
let minY = Infinity;
for (let s = 0; s < 60; s++) {
run(a, inputs, 1);
minY = Math.min(minY, a.pos[1]);
}
const dist = Math.hypot(a.pos[0] - start[0], a.pos[2] - start[1]);
assert.ok(Math.hypot(...a.vel) < 0.05, `still moving: V=${Math.hypot(...a.vel)}`);
assert.ok(dist < 400, `stopping distance ${dist.toFixed(0)} m >= 400`);
assert.ok(minY >= groundAt - 0.1, `CG sank to ${minY.toFixed(3)} m`);
});
// ---------------------------------------------------------------------------
// 5. Heavy impact: vertical sink 8 m/s with gear up → crashed.
// ---------------------------------------------------------------------------
test('vertical impact at 8 m/s with gear up crashes', () => {
const a = createAircraft();
a.pos = [0, cfg.wheelH + 2, WORLD.spawn.z];
a.groundContact = false;
a.airTime = 1;
a.vel = [0, 0, 8]; // body +Z = world down at level attitude
a.gearDown = false;
const inputs = { elevator: 0, aileron: 0, rudder: 0, throttle: 0,
flaps: 0.25, gear: false, brakes: true };
run(a, inputs, 1);
assert.ok(a.crashed, 'aircraft must crash on a hard gear-up impact');
assert.ok(a.impactSpeed >= 7,
`impactSpeed ${a.impactSpeed.toFixed(2)} should be ~ the 8 m/s sink`);
// sanity: gear-down version of the same descent is survivable
const b = createAircraft();
b.pos = [0, cfg.wheelH + 2, WORLD.spawn.z];
b.groundContact = false;
b.airTime = 1;
b.vel = [0, 0, 8];
b.gearDown = true;
const insB = { elevator: 0, aileron: 0, rudder: 0, throttle: 0,
flaps: 0.25, gear: true, brakes: true };
run(b, insB, 1);
// 8 m/s > maxTouchVz (3) with gear down is also a crash by the SPEC rule —
// this is just a hard landing, allowed only ≤ 3 m/s.
assert.ok(b.crashed, '8 m/s still exceeds the gear-down 3 m/s limit');
});
// ---------------------------------------------------------------------------
// 6. Quaternion unit norm after 2000 substeps.
// ---------------------------------------------------------------------------
test('quaternion stays unit-norm after 2000 substeps', () => {
const a = createAircraft();
a.pos = [0, 1200, 0];
a.flaps = 0;
a.gearDown = false;
a.angVel = [1.2, -0.8, 1.5];
a.vel = [40, 5, -10];
const inputs = { elevator: 0.7, aileron: -0.9, rudder: 0.4, throttle: 0.5,
flaps: 0, gear: false, brakes: false };
for (let i = 0; i < 2000; i++) step(a, inputs, 1 / 240);
const n = Math.hypot(a.quat[0], a.quat[1], a.quat[2], a.quat[3]);
assert.ok(Math.abs(n - 1) <= 1e-6, `|q| = ${n} drifted by ${(n - 1).toExponential(2)}`);
});
// ---------------------------------------------------------------------------
// 7. Lateral symmetry: 3 s full roll right vs roll left → |bank| within 2%.
// (Engine off so prop torque/gyro asymmetry cannot bias the comparison.)
// ---------------------------------------------------------------------------
test('roll-right vs roll-left for 3 s give symmetric |bank| (≤2%)', () => {
function roll(sign) {
const a = createAircraft();
a.pos = [0, 1500, 0];
a.flaps = 0;
a.gearDown = false;
a.rpm = 0;
a.throttle = 0;
a.engineOn = false;
a.vel = [60, 0, 0];
const inputs = { elevator: 0, aileron: sign, rudder: 0, throttle: 0,
flaps: 0, gear: false, brakes: false };
run(a, inputs, 3);
return Math.abs(getInstrumentData(a).bankDeg);
}
const bR = roll(1);
const bL = roll(-1);
const base = Math.min(bR, bL);
assert.ok(base > 10, `roll was too weak (|bank| ${base.toFixed(1)}°)`);
assert.ok(Math.abs(bR - bL) / base <= 0.02,
`|bank| right ${bR.toFixed(1)}° vs left ${bL.toFixed(1)}° differ > 2%`);
});
// ---------------------------------------------------------------------------
// 8. getInstrumentData completeness + sane ranges (every SPEC field).
// ---------------------------------------------------------------------------
test('getInstrumentData returns every SPEC field with sane values', () => {
const required = [
'airspeedMps', 'airspeedKts', 'altM', 'altFt', 'vsMps', 'vsFpm',
'headingDeg', 'pitchDeg', 'bankDeg', 'rollRateDps', 'pitchRateDps', 'yawRateDps',
'alphaDeg', 'betaDeg', 'gLoad', 'stall', 'engineOn', 'rpm', 'throttle',
'flapsPos', 'gearDown', 'brakes', 'fuelKg', 'groundContact', 'landed',
'crashed', 'onGround', 'rolling', 'airTimeS',
];
// parked state
const parked = getInstrumentData(createAircraft());
for (const k of required) {
assert.ok(k in parked, `missing field ${k}`);
assert.ok(Number.isFinite(parked[k]) || typeof parked[k] === 'boolean',
`non-finite ${k}`);
}
// airborne cruise state
const { a, inputs } = cruiseState(1000, 0.7);
run(a, inputs, 5);
const d = getInstrumentData(a);
for (const k of required) {
assert.ok(Number.isFinite(d[k]) || typeof d[k] === 'boolean', `${k} bad`);
}
assert.ok(d.airspeedKts > 100 && d.airspeedKts < 200, `airspeedKts ${d.airspeedKts}`);
assert.ok(d.altM > 900 && d.altM < 1100, `altM ${d.altM}`);
assert.ok(d.rpm >= 0 && d.rpm <= 1, `rpm ${d.rpm}`);
assert.ok(d.throttle >= 0 && d.throttle <= 1, `throttle ${d.throttle}`);
assert.ok(d.flapsPos >= 0 && d.flapsPos <= 1, `flapsPos ${d.flapsPos}`);
assert.ok(d.fuelKg > 0 && d.fuelKg <= cfg.fuelKg, `fuelKg ${d.fuelKg}`);
assert.ok(Math.abs(d.alphaDeg) < 45, `alphaDeg ${d.alphaDeg}`);
assert.ok(Math.abs(d.betaDeg) < 45, `betaDeg ${d.betaDeg}`);
assert.ok(d.gLoad > 0.5 && d.gLoad < 3, `gLoad ${d.gLoad}`);
assert.ok(d.engineOn === true, 'engine must be running in cruise');
assert.ok(d.crashed === false && d.groundContact === false, 'must be airborne');
});
// ---------------------------------------------------------------------------
// 9. Envelope: cruise at 3000 m @ 80% throttle for 30 s stays on-trim,
// never stalls, fuel depletes monotonically.
// ---------------------------------------------------------------------------
test('3000 m / 80% cruise stays within envelope (V, alt, no stall, fuel)', () => {
const { a, inputs, Vt } = cruiseState(3000, 0.8);
assert.ok(Vt * KTS >= 120 && Vt * KTS <= 180, `3000 m cruise ${Vt * KTS} kts`);
let minAlt = Infinity, maxAlt = -Infinity, stallSeen = false;
const fuel0 = a.fuelKg;
let prevFuel = fuel0;
for (let s = 0; s < 60; s++) { // 60 × 0.5 s = 30 s
run(a, inputs, 0.5);
const d = getInstrumentData(a);
minAlt = Math.min(minAlt, d.altM);
maxAlt = Math.max(maxAlt, d.altM);
if (d.stall) stallSeen = true;
assert.ok(d.fuelKg <= prevFuel + 1e-12, 'fuel must never increase');
prevFuel = d.fuelKg;
}
const d = getInstrumentData(a);
assert.ok(Math.abs(d.airspeedMps - Vt) / Vt <= 0.05,
`airspeed ${d.airspeedMps.toFixed(1)} m/s vs ${Vt.toFixed(1)} m/s`);
assert.ok(Math.abs(maxAlt - 3000) <= 200 && Math.abs(3000 - minAlt) <= 200,
`altitude drifted to [${minAlt.toFixed(0)}, ${maxAlt.toFixed(0)}] m`);
assert.ok(!stallSeen, 'cruise must not stall');
const consumed = fuel0 - a.fuelKg;
assert.ok(consumed > 0.8,
`fuel barely burned (${consumed.toFixed(2)} kg in 30 s @ 80%)`);
});
// ---------------------------------------------------------------------------
// 10. Rudder: full-ish right rudder for 5 s → consistent right turn
// (unwrapped heading advance, every second segment still right).
// ---------------------------------------------------------------------------
test('right rudder 5 s produces a consistent clockwise turn', () => {
const a = createAircraft();
a.pos = [0, 1500, 0];
a.flaps = 0;
a.gearDown = false;
a.rpm = 0;
a.throttle = 0;
a.engineOn = false;
a.vel = [60, 0, 0];
const inputs = { elevator: 0, aileron: 0, rudder: 0.5, throttle: 0,
flaps: 0, gear: false, brakes: false };
let unwrap = 0, prev = getInstrumentData(a).headingDeg;
const segments = [];
for (let s = 0; s < 5; s++) {
run(a, inputs, 1);
const h = getInstrumentData(a).headingDeg;
const dh = (h - prev + 540) % 360 - 180;
segments.push(dh);
unwrap += dh;
prev = h;
}
assert.ok(unwrap > 20, `right rudder must turn right (>20°), got ${unwrap.toFixed(1)}°`);
assert.ok(segments.every((d) => d >= -1),
`heading reversed mid-turn: ${segments.map((x) => x.toFixed(1)).join(', ')}`);
// mirror: left rudder must turn left
const b = createAircraft();
b.pos = [0, 1500, 0];
b.flaps = 0; b.gearDown = false; b.rpm = 0; b.throttle = 0; b.engineOn = false;
b.vel = [60, 0, 0];
const insL = { elevator: 0, aileron: 0, rudder: -0.5, throttle: 0,
flaps: 0, gear: false, brakes: false };
const dLeft = headingDrift(b, insL, 5);
assert.ok(dLeft < -20, `left rudder must turn left, got ${dLeft.toFixed(1)}°`);
});
// ---------------------------------------------------------------------------
// 11. Gear drag: at the same state, drag with gear down is measurably larger.
// ---------------------------------------------------------------------------
test('gear-down drag exceeds gear-up drag (>30%) at cruise speed', () => {
const mk = (gear) => {
const a = createAircraft();
a.pos = [0, 1500, 0];
a.flaps = 0;
a.gearDown = gear;
a.rpm = 0.3; a.throttle = 0.3; a.engineOn = true;
a.vel = [70, 0, 0];
return a;
};
const up = getAeroSummary(mk(false));
const down = getAeroSummary(mk(true));
assert.ok(down.dragN > up.dragN * 1.3,
`gear drag too small: up ${up.dragN.toFixed(0)} N vs down ${down.dragN.toFixed(0)} N`);
// gear drag also raises total CD measurably
assert.ok(down.cd > up.cd + cfg.dCD_gear * 0.5,
`CD delta too small: ${up.cd.toFixed(4)} -> ${down.cd.toFixed(4)}`);
});
// ---------------------------------------------------------------------------
// 12. Fuel depletes monotonically (and monotonically over a long cruise).
// ---------------------------------------------------------------------------
test('fuel depletes monotonically; enough burned in 120 s cruise', () => {
const { a, inputs } = cruiseState(1000, 0.7);
const fuel0 = a.fuelKg;
let prev = fuel0;
for (let i = 0; i < 240; i++) { // 2 s at 1/120
step(a, inputs, 1 / 120);
assert.ok(a.fuelKg <= prev + 1e-12, `fuel increased at step ${i}`);
prev = a.fuelKg;
}
assert.ok(fuel0 - a.fuelKg > 0.05,
`only ${(fuel0 - a.fuelKg).toFixed(3)} kg burned in 2 s of cruise ` +
`(burn rate ${(cfg.fuelBurn * 0.7 * 0.7).toFixed(4)} kg/s)`);
});
// ---------------------------------------------------------------------------
// 13. Determinism: two runs split differently (1/120 vs 1/240 steps) produce
// bit-identical state; NaN/Infinity inputs cannot poison the sim.
// ---------------------------------------------------------------------------
test('deterministic across substep splits; NaN inputs are harmless', () => {
const mk = () => {
const a = createAircraft();
a.pos = [0, 1200, 0];
a.flaps = 0; a.gearDown = false; a.rpm = 0.5; a.throttle = 0.5;
a.vel = [50, 2, -3]; a.angVel = [0.1, -0.2, 0.3];
return a;
};
const inputs = { elevator: 0.2, aileron: 0.3, rudder: -0.1, throttle: 0.5,
flaps: 0, gear: false, brakes: false };
const a1 = mk(), a2 = mk();
for (let i = 0; i < 480; i++) step(a1, inputs, 1 / 240);
for (let i = 0; i < 240; i++) step(a2, inputs, 1 / 120);
for (const k of ['pos', 'vel', 'angVel', 'quat']) {
for (let j = 0; j < a1[k].length; j++) {
assert.ok(a1[k][j] === a2[k][j],
`${k}[${j}] differs: ${a1[k][j]} vs ${a2[k][j]}`);
}
}
// NaN inputs must not poison the state
const a3 = createAircraft();
step(a3, { elevator: NaN, aileron: Infinity, rudder: NaN, throttle: NaN }, 1 / 120);
const d = getInstrumentData(a3);
assert.ok(Number.isFinite(d.pitchDeg) && Number.isFinite(d.bankDeg) &&
Number.isFinite(d.airspeedMps), 'sim state must stay finite after bad inputs');
});
// ---------------------------------------------------------------------------
// 14. resetAircraft: returns the aircraft to the spawn envelope (parked,
// level, on the runway, fuel restored).
// ---------------------------------------------------------------------------
test('resetAircraft restores spawn state exactly', () => {
const a = createAircraft();
a.pos = [100, 2000, -500];
a.vel = [40, 5, -3];
a.angVel = [0.5, 0.2, -0.4];
a.fuelKg = 3;
a.crashed = true;
a.airTime = 99;
resetAircraft(a);
const d = getInstrumentData(a);
assert.deepEqual(a.pos, a.spawnPos);
assert.deepEqual(a.quat, a.spawnQuat);
assert.ok(Math.hypot(...a.vel) === 0 && Math.hypot(...a.angVel) === 0);
assert.ok(a.fuelKg === cfg.fuelKg && a.crashed === false && a.airTime === 0);
assert.ok(d.groundContact === true);
assert.ok(Math.abs(d.bankDeg) < 1 && Math.abs(d.pitchDeg) < 1,
`reset attitude off: pitch ${d.pitchDeg} bank ${d.bankDeg}`);
});