1
0

feat: upgrade electric aircraft fidelity and flight model

This commit is contained in:
2026-08-19 00:52:35 -07:00
parent dd18c6775d
commit 79793faed2
4 changed files with 443 additions and 88 deletions
+143 -8
View File
@@ -65,6 +65,15 @@ export interface AircraftControllerOptions {
maximumSpeedMps?: number;
assistedCruiseMps?: number;
assistedAltitudeM?: number;
/** Deterministic scenario wind, positive north/east in metres per second. */
windNorthMps?: number;
windEastMps?: number;
/** Deterministic sinusoidal gust amplitude. Zero disables turbulence. */
turbulenceMps?: number;
stallSpeedMps?: number;
batteryCapacityWh?: number;
/** Terrain/runway elevation callback used for ground contact and landing. */
terrainElevationM?: (lat: number, lng: number) => number;
fixedStepSeconds?: number;
maxFrameDeltaSeconds?: number;
}
@@ -84,6 +93,17 @@ export interface AircraftControllerState extends AircraftGeographicPoint {
pitchInput: number;
rollInput: number;
fanRadians: number;
angleOfAttackDeg: number;
liftCoefficient: number;
loadFactorG: number;
stalled: boolean;
windNorthMps: number;
windEastMps: number;
batteryWh: number;
energyUsedWh: number;
groundClearanceM: number;
onGround: boolean;
hardLanding: boolean;
envelopeContact: boolean;
elapsedSteps: number;
}
@@ -118,6 +138,12 @@ interface ResolvedOptions {
maximumSpeedMps: number;
assistedCruiseMps: number;
assistedAltitudeM: number;
windNorthMps: number;
windEastMps: number;
turbulenceMps: number;
stallSpeedMps: number;
batteryCapacityWh: number;
terrainElevationM: (lat: number, lng: number) => number;
fixedStepSeconds: number;
maxFrameDeltaSeconds: number;
}
@@ -199,6 +225,15 @@ function resolveOptions(value: AircraftControllerOptions): ResolvedOptions {
envelope.minAltitudeM,
envelope.maxAltitudeM,
);
const stallSpeedMps = clamp(
finiteOr(value.stallSpeedMps, Math.max(18, minimumSpeedMps + 3)),
minimumSpeedMps,
maximumSpeedMps * 0.8,
);
const terrainElevationM = value.terrainElevationM ?? (() => envelope.minAltitudeM);
if (typeof terrainElevationM !== "function") {
throw new RangeError("aircraft terrainElevationM must be a function");
}
return {
initialPosition: {
lat: clamp(finiteOr(value.initialPosition?.lat, 34.0522), envelope.minLat, envelope.maxLat),
@@ -214,6 +249,12 @@ function resolveOptions(value: AircraftControllerOptions): ResolvedOptions {
maximumSpeedMps,
assistedCruiseMps: clamp(finiteOr(value.assistedCruiseMps, 62), minimumSpeedMps, maximumSpeedMps),
assistedAltitudeM,
windNorthMps: clamp(finiteOr(value.windNorthMps, 0), -80, 80),
windEastMps: clamp(finiteOr(value.windEastMps, 0), -80, 80),
turbulenceMps: clamp(finiteOr(value.turbulenceMps, 0), 0, 30),
stallSpeedMps,
batteryCapacityWh: clamp(finiteOr(value.batteryCapacityWh, 54_000), 1_000, 500_000),
terrainElevationM,
fixedStepSeconds: clamp(finiteOr(value.fixedStepSeconds, 1 / 60), 1 / 240, 0.1),
maxFrameDeltaSeconds: clamp(finiteOr(value.maxFrameDeltaSeconds, 0.25), 0.05, 1),
};
@@ -264,6 +305,17 @@ export class AircraftController {
pitchInput: 0,
rollInput: 0,
fanRadians: 0,
angleOfAttackDeg: 0,
liftCoefficient: 1,
loadFactorG: 1,
stalled: false,
windNorthMps: this.options.windNorthMps,
windEastMps: this.options.windEastMps,
batteryWh: this.options.batteryCapacityWh,
energyUsedWh: 0,
groundClearanceM: 0,
onGround: false,
hardLanding: false,
envelopeContact: false,
elapsedSteps: 0,
};
@@ -299,11 +351,33 @@ export class AircraftController {
pitchInput: 0,
rollInput: 0,
fanRadians: 0,
angleOfAttackDeg: 0,
liftCoefficient: 1,
loadFactorG: 1,
stalled: false,
windNorthMps: this.options.windNorthMps,
windEastMps: this.options.windEastMps,
batteryWh: this.options.batteryCapacityWh,
energyUsedWh: 0,
groundClearanceM: Math.max(0, this.options.initialAltitudeM - this.groundElevationM(
this.options.initialPosition.lat,
this.options.initialPosition.lng,
)),
onGround: false,
hardLanding: false,
envelopeContact: false,
elapsedSteps: 0,
});
}
private groundElevationM(lat: number, lng: number): number {
const value = this.options.terrainElevationM(lat, lng);
if (!Number.isFinite(value)) {
throw new RangeError("aircraft terrainElevationM must return a finite elevation");
}
return clamp(value, this.options.envelope.minAltitudeM, this.options.envelope.maxAltitudeM);
}
tick(deltaSeconds: number, actions: Partial<AircraftActionSnapshot> = NEUTRAL_AIRCRAFT_ACTIONS): number {
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
const normalized = normalizeAircraftActions(actions);
@@ -358,12 +432,28 @@ export class AircraftController {
this.current.pitchInput = moveToward(this.current.pitchInput, pitch, 2.2 * dt);
this.current.rollInput = moveToward(this.current.rollInput, roll, 2.8 * dt);
const targetRoll = this.current.rollInput * 58;
const targetPitch = this.current.pitchInput * 22;
const gustPhase = this.current.elapsedSteps * dt * 0.73;
const gustNorth = Math.sin(gustPhase) * this.options.turbulenceMps;
const gustEast = Math.sin(gustPhase * 0.61 + 1.7) * this.options.turbulenceMps * 0.72;
this.current.windNorthMps = this.options.windNorthMps + gustNorth;
this.current.windEastMps = this.options.windEastMps + gustEast;
const targetRoll = this.current.rollInput * 58 + gustEast * 0.22;
const targetPitch = this.current.pitchInput * 22 + gustNorth * 0.08;
this.current.rollDeg = moveToward(this.current.rollDeg, targetRoll, 55 * dt);
this.current.pitchDeg = moveToward(this.current.pitchDeg, targetPitch, 28 * dt);
const thrust = this.current.throttle * 8.5;
const drag = 1.2 + this.current.speedMps * this.current.speedMps * 0.00075;
const flightPathDeg = Math.atan2(
this.current.verticalSpeedMps,
Math.max(1, this.current.speedMps),
) * 180 / Math.PI;
this.current.angleOfAttackDeg = this.current.pitchDeg - flightPathDeg;
this.current.liftCoefficient = clamp(1 + this.current.angleOfAttackDeg * 0.055, 0.05, 1.6);
this.current.stalled = this.current.speedMps < this.options.stallSpeedMps ||
Math.abs(this.current.angleOfAttackDeg) > 19;
const stallDrag = this.current.stalled ? 3.8 : 0;
const batteryFactor = clamp(this.current.batteryWh / Math.max(1, this.options.batteryCapacityWh * 0.08), 0, 1);
const thrust = this.current.throttle * 8.5 * batteryFactor;
const drag = 1.2 + this.current.speedMps * this.current.speedMps * 0.00075 + stallDrag;
this.current.speedMps = clamp(
this.current.speedMps + (thrust - drag) * dt,
this.options.minimumSpeedMps,
@@ -373,16 +463,55 @@ export class AircraftController {
this.current.headingDeg = wrapDegrees(
this.current.headingDeg + (bankTurn + this.current.yawInput * 20) * dt,
);
this.current.verticalSpeedMps = Math.sin(this.current.pitchDeg * Math.PI / 180) * this.current.speedMps;
const liftAuthority = clamp(
this.current.liftCoefficient * (this.current.speedMps / Math.max(1, this.options.assistedCruiseMps)),
0.08,
1.35,
);
const commandedVerticalSpeed = Math.sin(this.current.pitchDeg * Math.PI / 180) *
this.current.speedMps * liftAuthority;
const stallSinkMps = this.current.stalled
? clamp((this.options.stallSpeedMps - this.current.speedMps) * 0.7 + 2.5, 2.5, 14)
: 0;
this.current.verticalSpeedMps = moveToward(
this.current.verticalSpeedMps,
commandedVerticalSpeed - stallSinkMps,
(this.current.stalled ? 8 : 5) * dt,
);
this.current.loadFactorG = clamp(
liftAuthority / Math.max(0.25, Math.cos(this.current.rollDeg * Math.PI / 180)),
0,
3.5,
);
const heading = this.current.headingDeg * Math.PI / 180;
const horizontalSpeed = Math.cos(this.current.pitchDeg * Math.PI / 180) * this.current.speedMps;
const northM = Math.cos(heading) * horizontalSpeed * dt;
const eastM = Math.sin(heading) * horizontalSpeed * dt;
const northM = (Math.cos(heading) * horizontalSpeed + this.current.windNorthMps) * dt;
const eastM = (Math.sin(heading) * horizontalSpeed + this.current.windEastMps) * dt;
const nextLat = this.current.lat + northM / EARTH_RADIUS_M * 180 / Math.PI;
const nextLng = this.current.lng + eastM /
(EARTH_RADIUS_M * Math.max(0.01, Math.cos(this.current.lat * Math.PI / 180))) * 180 / Math.PI;
const nextAltitude = this.current.altitudeM + this.current.verticalSpeedMps * dt;
let nextAltitude = this.current.altitudeM + this.current.verticalSpeedMps * dt;
const groundElevationM = this.groundElevationM(nextLat, nextLng);
const wasVerticalSpeedMps = this.current.verticalSpeedMps;
this.current.onGround = nextAltitude <= groundElevationM + 0.75;
this.current.hardLanding = this.current.onGround && wasVerticalSpeedMps < -4.5;
if (this.current.onGround) {
nextAltitude = groundElevationM;
this.current.verticalSpeedMps = 0;
this.current.rollDeg = moveToward(this.current.rollDeg, 0, 70 * dt);
this.current.pitchDeg = moveToward(this.current.pitchDeg, 0, 45 * dt);
if (this.current.throttle < 0.55) {
this.current.speedMps = Math.max(this.options.minimumSpeedMps, this.current.speedMps - 4 * dt);
} else if (
this.current.speedMps > this.options.stallSpeedMps * 1.08 &&
this.current.pitchInput > 0.2
) {
this.current.onGround = false;
nextAltitude = groundElevationM + 0.8;
this.current.verticalSpeedMps = 0.8;
}
}
const envelope = this.options.envelope;
this.current.envelopeContact =
nextLat < envelope.minLat || nextLat > envelope.maxLat ||
@@ -391,12 +520,18 @@ export class AircraftController {
this.current.lat = clamp(nextLat, envelope.minLat, envelope.maxLat);
this.current.lng = clamp(nextLng, envelope.minLng, envelope.maxLng);
this.current.altitudeM = clamp(nextAltitude, envelope.minAltitudeM, envelope.maxAltitudeM);
this.current.groundClearanceM = Math.max(0, this.current.altitudeM - groundElevationM);
if (this.current.envelopeContact) {
this.current.speedMps = Math.max(this.options.minimumSpeedMps, this.current.speedMps * 0.92);
this.current.pitchDeg = moveToward(this.current.pitchDeg, 0, 90 * dt);
this.current.rollDeg = moveToward(this.current.rollDeg, 0, 90 * dt);
}
this.current.fanRadians = (this.current.fanRadians + (30 + this.current.throttle * 180) * dt) % TWO_PI;
const electricalPowerW = 8_000 + this.current.throttle * 122_000 +
Math.abs(this.current.verticalSpeedMps) * 420;
const usedWh = Math.min(this.current.batteryWh, electricalPowerW * dt / 3_600);
this.current.batteryWh -= usedWh;
this.current.energyUsedWh += usedWh;
this.current.elapsedSteps += 1;
}
}