feat: upgrade electric aircraft fidelity and flight model
This commit is contained in:
+143
-8
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user