Add JSBSim service backend

This commit is contained in:
moriy
2026-04-28 17:53:29 +08:00
parent b025708b5a
commit 25297601d7
14 changed files with 5053 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const DEFAULT_RUNNER = resolve(serviceRoot, "scripts", "jsbsim_runner.py");
const runnerPath = process.env.JSBSIM_RUNNER ?? DEFAULT_RUNNER;
const pythonCommand = process.env.JSBSIM_PYTHON ?? "python";
export const backendInfo = {
id: "jsbsim",
name: "JSBSim",
ready: true,
runner: runnerPath,
};
export const AIRCRAFT = [
{
id: "f22cobra-jsbsim",
name: "F-22 Cobra Research JSBSim",
source: "JSBSim",
capabilities: {
basic6dof: true,
highAlpha: true,
postStall: "experimental",
thrustVectoring: true,
requiresNoTrimForManeuverResearch: true,
},
},
{
id: "f22-jsbsim",
name: "F-22 JSBSim",
source: "JSBSim",
capabilities: {
basic6dof: true,
highAlpha: true,
postStall: "experimental",
thrustVectoring: true,
requiresNoTrimForManeuverResearch: true,
},
},
{
id: "f16-jsbsim",
name: "F-16 JSBSim",
source: "JSBSim",
capabilities: {
basic6dof: true,
highAlpha: "limited",
postStall: false,
thrustVectoring: false,
},
},
{
id: "c172p-jsbsim",
name: "Cessna 172P JSBSim",
source: "JSBSim",
capabilities: {
basic6dof: true,
highAlpha: false,
postStall: false,
thrustVectoring: false,
},
},
];
export function trimInitialState(request) {
validateTrimRequest(request);
return runJsbsimRunner({
operation: "trim",
aircraftId: request.aircraftId,
sampleRateHz: 30,
integrationRateHz: numberOrDefault(request.integrationRateHz, 120),
trim: request,
});
}
export function simulateManeuver(request) {
validateSimulateRequest(request);
return runJsbsimRunner({
operation: "simulate",
aircraftId: request.aircraftId.replace("-jsbsim", ""),
sampleRateHz: numberOrDefault(request.sampleRateHz, 30),
integrationRateHz: numberOrDefault(request.integrationRateHz, 120),
durationSec: numberOrDefault(request.durationSec, 10),
trim: request.trim,
initialState: request.initialState,
maneuver: request.maneuver,
});
}
function runJsbsimRunner(payload) {
const result = spawnSync(pythonCommand, [runnerPath], {
input: JSON.stringify(payload),
encoding: "utf8",
maxBuffer: 20 * 1024 * 1024,
});
if (result.error) {
throw new Error(
`Unable to run JSBSim runner with "${pythonCommand}". Install Python and jsbsim, or set JSBSIM_PYTHON/JSBSIM_RUNNER. ${result.error.message}`
);
}
if (result.status !== 0) {
if (result.status === 9009) {
throw new Error(
`Unable to run JSBSim runner with "${pythonCommand}". Python is not available on PATH; install Python and jsbsim, or set JSBSIM_PYTHON to a Python executable.`
);
}
throw new Error(
`JSBSim runner failed with exit code ${result.status}: ${result.stderr.trim()}`
);
}
try {
const response = JSON.parse(extractJson(result.stdout));
return {
...response,
backend: backendInfo.id,
};
} catch {
throw new Error(`JSBSim runner did not return valid JSON: ${result.stdout}`);
}
}
function extractJson(stdout) {
const firstObject = stdout.indexOf("{");
if (firstObject < 0) {
throw new Error("No JSON payload found.");
}
return stdout.slice(firstObject).trim();
}
function validateTrimRequest(request) {
if (!request || typeof request !== "object") {
throw new Error("Request body must be a JSON object.");
}
if (!AIRCRAFT.some((aircraft) => aircraft.id === request.aircraftId)) {
throw new Error(`Unknown aircraftId: ${request.aircraftId}`);
}
}
function validateSimulateRequest(request) {
if (!request || typeof request !== "object") {
throw new Error("Request body must be a JSON object.");
}
if (!AIRCRAFT.some((aircraft) => aircraft.id === request.aircraftId)) {
throw new Error(`Unknown aircraftId: ${request.aircraftId}`);
}
if (!request.initialState || typeof request.initialState !== "object") {
throw new Error("initialState is required.");
}
const maneuverType = request.maneuver?.type;
const supported = new Set(["straight", "climb", "descent", "control-script", "cobra"]);
if (!supported.has(maneuverType)) {
throw new Error(
`JSBSim minimal backend supports straight/climb/descent/control-script/cobra only. Unsupported maneuver.type: ${maneuverType}`
);
}
}
function numberOrDefault(value, fallback) {
return Number.isFinite(value) ? value : fallback;
}

View File

@@ -0,0 +1,472 @@
import { randomUUID } from "node:crypto";
const EARTH_GRAVITY_MPS2 = 9.80665;
const SPEED_OF_SOUND_SEA_LEVEL_MPS = 340.29;
export const backendInfo = {
id: "mock",
name: "Mock Dynamics",
ready: true,
};
export const AIRCRAFT = [
{
id: "f16-mock",
name: "F-16 Mock Dynamics",
source: "Mock",
capabilities: {
basic6dof: false,
highAlpha: "visual-only",
postStall: "visual-only",
thrustVectoring: false,
},
},
];
export function trimInitialState(request) {
const altitudeM = numberOrDefault(request.altitudeM, 1000);
const speedMps = numberOrDefault(request.speedMps, 120);
const headingDeg = normalizeHeading(numberOrDefault(request.headingDeg, 0));
const flightPathAngleDeg = numberOrDefault(request.flightPathAngleDeg, 0);
return {
success: true,
backend: backendInfo.id,
state: {
position: [0, altitudeM, 0],
rotation: eulerToQuat(0, flightPathAngleDeg, -headingDeg),
velocityMps: speedMps,
headingDeg,
alphaDeg: 3.2,
betaDeg: 0,
},
};
}
export function simulateManeuver(request) {
validateSimulateRequest(request);
const sampleRateHz = clamp(numberOrDefault(request.sampleRateHz, 30), 1, 240);
const integrationRateHz = clamp(numberOrDefault(request.integrationRateHz, 120), 1, 1000);
const durationSec = clamp(numberOrDefault(request.durationSec, 10), 0.1, 3600);
const totalSamples = Math.floor(durationSec * sampleRateHz) + 1;
const samples = [];
const initial = request.initialState;
const startPosition = vec3OrDefault(initial.position, [0, 1000, 0]);
const startVelocity = numberOrDefault(initial.velocityMps, 120);
const startHeading = normalizeHeading(numberOrDefault(initial.headingDeg, 0));
const maneuver = request.maneuver ?? { type: "straight", parameters: {} };
const parameters = maneuver.parameters ?? {};
for (let index = 0; index < totalSamples; index += 1) {
const t = Math.min(index / sampleRateHz, durationSec);
samples.push(
sampleAtTime({
t,
maneuverType: maneuver.type,
parameters,
startPosition,
startVelocity,
startHeading,
})
);
}
return {
id: `sim_${randomUUID()}`,
backend: backendInfo.id,
aircraftId: request.aircraftId,
sampleRateHz,
integrationRateHz,
durationSec,
coordinateFrame: "local-eun",
headingConvention:
"true heading: 0 deg north, clockwise positive, counter-clockwise negative",
samples,
};
}
function sampleAtTime({
t,
maneuverType,
parameters,
startPosition,
startVelocity,
startHeading,
}) {
const speedMps = Math.max(1, numberOrDefault(parameters.speedMps, startVelocity));
let headingDeg = startHeading;
let east = startPosition[0];
let up = startPosition[1];
let north = startPosition[2];
let pitchDeg = 0;
let rollDeg = 0;
let loadFactor = 1;
let alphaDeg = 3.2;
let verticalSpeedMps = 0;
if (maneuverType === "level-turn") {
const turnRateDegPerSec = numberOrDefault(
parameters.turnRateDegPerSec,
inferTurnRate(parameters.bankDeg, speedMps)
);
const turnRateRadPerSec = degToRad(turnRateDegPerSec);
headingDeg = normalizeHeading(startHeading + turnRateDegPerSec * t);
rollDeg = numberOrDefault(
parameters.bankDeg,
inferBankDeg(turnRateDegPerSec, speedMps)
);
loadFactor = 1 / Math.max(0.2, Math.cos(degToRad(Math.abs(rollDeg))));
alphaDeg = 3.2 + Math.max(0, loadFactor - 1) * 1.4;
if (Math.abs(turnRateRadPerSec) < 0.000001) {
const straight = headingDistance(startHeading, speedMps * t);
east += straight[0];
north += straight[1];
} else {
const radiusM = speedMps / turnRateRadPerSec;
const initialHeadingRad = degToRad(startHeading);
const currentHeadingRad = initialHeadingRad + turnRateRadPerSec * t;
east += radiusM * (Math.cos(initialHeadingRad) - Math.cos(currentHeadingRad));
north += radiusM * (Math.sin(currentHeadingRad) - Math.sin(initialHeadingRad));
}
} else if (maneuverType === "cobra" || maneuverType === "control-script") {
const controls = controlInputsAtTime(maneuverType, parameters, t);
const profile = postStallProfile(maneuverType, parameters, t, startVelocity);
const distance = profile.forwardDistanceM;
const horizontal = headingDistance(startHeading, distance);
east += horizontal[0];
north += horizontal[1];
up += profile.altitudeDeltaM;
pitchDeg = profile.pitchDeg;
alphaDeg = profile.alphaDeg;
loadFactor = profile.loadFactor;
return {
t,
position: [round(east), round(up), round(north)],
rotation: eulerToQuat(rollDeg, pitchDeg, -headingDeg),
velocityMps: round(profile.speedMps),
headingDeg: round(headingDeg),
pitchDeg: round(pitchDeg),
rollDeg: round(rollDeg),
alphaDeg: round(alphaDeg),
betaDeg: 0,
loadFactor: round(loadFactor),
mach: round(profile.speedMps / SPEED_OF_SOUND_SEA_LEVEL_MPS),
qbar: round(0.5 * 1.225 * profile.speedMps * profile.speedMps),
verticalSpeedMps: round(profile.verticalSpeedMps),
pitchRateDegS: round(profile.pitchRateDegS),
rollRateDegS: 0,
yawRateDegS: 0,
throttle: controls.throttle,
controlInputs: controls,
controlSurfaces: {
elevatorDeg: round(controls.elevator * 25),
aileronDeg: round(controls.aileron * 21),
rudderDeg: round(controls.rudder * 30),
},
};
} else {
const flightPathAngleDeg =
maneuverType === "climb"
? Math.abs(numberOrDefault(parameters.flightPathAngleDeg, 8))
: maneuverType === "descent"
? -Math.abs(numberOrDefault(parameters.flightPathAngleDeg, 8))
: numberOrDefault(parameters.flightPathAngleDeg, 0);
const horizontalSpeed = speedMps * Math.cos(degToRad(flightPathAngleDeg));
const verticalSpeed = speedMps * Math.sin(degToRad(flightPathAngleDeg));
const distance = horizontalSpeed * t;
const horizontal = headingDistance(startHeading, distance);
east += horizontal[0];
north += horizontal[1];
up += verticalSpeed * t;
pitchDeg = flightPathAngleDeg;
alphaDeg = 3.2 + Math.max(0, flightPathAngleDeg) * 0.08;
verticalSpeedMps = verticalSpeed;
}
return {
t,
position: [round(east), round(up), round(north)],
rotation: eulerToQuat(rollDeg, pitchDeg, -headingDeg),
velocityMps: round(speedMps),
headingDeg: round(headingDeg),
pitchDeg: round(pitchDeg),
rollDeg: round(rollDeg),
alphaDeg: round(alphaDeg),
betaDeg: 0,
loadFactor: round(loadFactor),
mach: round(speedMps / SPEED_OF_SOUND_SEA_LEVEL_MPS),
qbar: round(0.5 * 1.225 * speedMps * speedMps),
verticalSpeedMps: round(verticalSpeedMps),
pitchRateDegS: 0,
rollRateDegS: 0,
yawRateDegS: 0,
throttle: 0.82,
controlInputs: {
elevator: 0,
aileron: 0,
rudder: 0,
throttle: 0.82,
},
controlSurfaces: {
elevatorDeg: round(pitchDeg * 0.15),
aileronDeg: round(rollDeg * 0.1),
rudderDeg: 0,
},
};
}
function validateSimulateRequest(request) {
if (!request || typeof request !== "object") {
throw new Error("Request body must be a JSON object.");
}
if (!AIRCRAFT.some((aircraft) => aircraft.id === request.aircraftId)) {
throw new Error(`Unknown aircraftId: ${request.aircraftId}`);
}
if (!request.initialState || typeof request.initialState !== "object") {
throw new Error("initialState is required.");
}
if (!request.maneuver || typeof request.maneuver !== "object") {
throw new Error("maneuver is required.");
}
const allowedManeuvers = new Set([
"straight",
"climb",
"descent",
"level-turn",
"control-script",
"cobra",
]);
if (!allowedManeuvers.has(request.maneuver.type)) {
throw new Error(`Unsupported maneuver.type: ${request.maneuver.type}`);
}
}
function controlInputsAtTime(maneuverType, parameters, t) {
const timeline =
maneuverType === "cobra" ? createCobraTimeline(parameters) : parameters.timeline;
if (!Array.isArray(timeline) || timeline.length === 0) {
return { elevator: 0, aileron: 0, rudder: 0, throttle: 0.82 };
}
const points = timeline
.filter((point) => point && typeof point === "object")
.toSorted((a, b) => numberOrDefault(a.t, 0) - numberOrDefault(b.t, 0));
if (points.length === 0 || t <= numberOrDefault(points[0].t, 0)) {
return normalizeControlPoint(points[0]);
}
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
const previousT = numberOrDefault(previous.t, 0);
const currentT = numberOrDefault(current.t, previousT);
if (t <= currentT) {
const amount = currentT <= previousT ? 0 : (t - previousT) / (currentT - previousT);
return interpolateControls(
normalizeControlPoint(previous),
normalizeControlPoint(current),
amount
);
}
}
return normalizeControlPoint(points.at(-1));
}
function createCobraTimeline(parameters) {
const pullDuration = Math.max(0.1, numberOrDefault(parameters.pullDurationSec, 0.9));
const unloadDuration = Math.max(0.1, numberOrDefault(parameters.unloadDurationSec, 0.7));
const recoveryDuration = Math.max(0.1, numberOrDefault(parameters.recoveryDurationSec, 2.0));
const maxElevatorCmd = clamp(numberOrDefault(parameters.maxElevatorCmd, -1), -1, 1);
const recoveryElevatorCmd = clamp(numberOrDefault(parameters.recoveryElevatorCmd, 0.35), -1, 1);
const throttleCmd = clamp(numberOrDefault(parameters.throttleCmd, 1), 0, 1);
return [
{ t: 0, elevator: 0, aileron: 0, rudder: 0, throttle: throttleCmd },
{ t: 0.2, elevator: maxElevatorCmd, aileron: 0, rudder: 0, throttle: throttleCmd },
{
t: 0.2 + pullDuration,
elevator: 0,
aileron: 0,
rudder: 0,
throttle: throttleCmd,
},
{
t: 0.2 + pullDuration + unloadDuration,
elevator: recoveryElevatorCmd,
aileron: 0,
rudder: 0,
throttle: throttleCmd,
},
{
t: 0.2 + pullDuration + unloadDuration + recoveryDuration,
elevator: 0,
aileron: 0,
rudder: 0,
throttle: throttleCmd,
},
];
}
function postStallProfile(maneuverType, parameters, t, startVelocity) {
if (maneuverType === "control-script") {
const controls = controlInputsAtTime(maneuverType, parameters, t);
const pitchDeg = controls.elevator * 35;
const speedMps = Math.max(30, startVelocity - Math.abs(pitchDeg) * 0.4);
return {
pitchDeg,
alphaDeg: 3.2 + Math.max(0, pitchDeg) * 0.8,
speedMps,
forwardDistanceM: speedMps * t,
altitudeDeltaM: Math.sin(degToRad(pitchDeg)) * speedMps * t * 0.15,
verticalSpeedMps: Math.sin(degToRad(pitchDeg)) * speedMps,
pitchRateDegS: controls.elevator * 45,
loadFactor: 1 + Math.max(0, controls.elevator) * 1.8,
};
}
const pullDuration = Math.max(0.1, numberOrDefault(parameters.pullDurationSec, 0.9));
const unloadDuration = Math.max(0.1, numberOrDefault(parameters.unloadDurationSec, 0.7));
const recoveryDuration = Math.max(0.1, numberOrDefault(parameters.recoveryDurationSec, 2.0));
const peakPitchDeg = numberOrDefault(parameters.peakPitchDeg, 105);
const tPullEnd = 0.2 + pullDuration;
const tUnloadEnd = tPullEnd + unloadDuration;
const tRecoveryEnd = tUnloadEnd + recoveryDuration;
let pitchDeg;
if (t <= 0.2) {
pitchDeg = 0;
} else if (t <= tPullEnd) {
pitchDeg = smoothstep((t - 0.2) / pullDuration) * peakPitchDeg;
} else if (t <= tUnloadEnd) {
pitchDeg = peakPitchDeg - smoothstep((t - tPullEnd) / unloadDuration) * 35;
} else if (t <= tRecoveryEnd) {
pitchDeg = (peakPitchDeg - 35) * (1 - smoothstep((t - tUnloadEnd) / recoveryDuration));
} else {
pitchDeg = 0;
}
const speedDrop = Math.min(startVelocity * 0.62, Math.max(0, pitchDeg) * 0.9);
const speedMps = Math.max(35, startVelocity - speedDrop);
const forwardFactor = Math.max(0.18, Math.cos(degToRad(Math.min(Math.abs(pitchDeg), 82))));
const forwardDistanceM = speedMps * t * forwardFactor;
const altitudeDeltaM = Math.sin(degToRad(Math.min(pitchDeg, 75))) * speedMps * Math.min(t, 3.5) * 0.18;
return {
pitchDeg,
alphaDeg: Math.max(3.2, pitchDeg * 0.95),
speedMps,
forwardDistanceM,
altitudeDeltaM,
verticalSpeedMps: Math.sin(degToRad(Math.min(pitchDeg, 75))) * speedMps,
pitchRateDegS: t <= tPullEnd ? peakPitchDeg / pullDuration : -peakPitchDeg / recoveryDuration,
loadFactor: 1 + Math.max(0, pitchDeg / peakPitchDeg) * 2.5,
};
}
function normalizeControlPoint(point = {}) {
return {
elevator: clamp(numberOrDefault(point.elevator, 0), -1, 1),
aileron: clamp(numberOrDefault(point.aileron, 0), -1, 1),
rudder: clamp(numberOrDefault(point.rudder, 0), -1, 1),
throttle: clamp(numberOrDefault(point.throttle, 0.82), 0, 1),
};
}
function interpolateControls(start, end, amount) {
return {
elevator: round(start.elevator + (end.elevator - start.elevator) * amount),
aileron: round(start.aileron + (end.aileron - start.aileron) * amount),
rudder: round(start.rudder + (end.rudder - start.rudder) * amount),
throttle: round(start.throttle + (end.throttle - start.throttle) * amount),
};
}
function smoothstep(value) {
const x = clamp(value, 0, 1);
return x * x * (3 - 2 * x);
}
function headingDistance(headingDeg, distanceM) {
const headingRad = degToRad(headingDeg);
return [Math.sin(headingRad) * distanceM, Math.cos(headingRad) * distanceM];
}
function inferTurnRate(bankDeg, speedMps) {
const bankRad = degToRad(numberOrDefault(bankDeg, 30));
return radToDeg((EARTH_GRAVITY_MPS2 * Math.tan(bankRad)) / Math.max(1, speedMps));
}
function inferBankDeg(turnRateDegPerSec, speedMps) {
const turnRateRadPerSec = degToRad(turnRateDegPerSec);
return radToDeg(Math.atan((turnRateRadPerSec * speedMps) / EARTH_GRAVITY_MPS2));
}
function eulerToQuat(rollDeg, pitchDeg, yawDeg) {
const roll = degToRad(rollDeg);
const pitch = degToRad(pitchDeg);
const yaw = degToRad(yawDeg);
const cy = Math.cos(yaw * 0.5);
const sy = Math.sin(yaw * 0.5);
const cp = Math.cos(pitch * 0.5);
const sp = Math.sin(pitch * 0.5);
const cr = Math.cos(roll * 0.5);
const sr = Math.sin(roll * 0.5);
return [
round(sr * cp * cy - cr * sp * sy),
round(cr * sp * cy + sr * cp * sy),
round(cr * cp * sy - sr * sp * cy),
round(cr * cp * cy + sr * sp * sy),
];
}
function normalizeHeading(value) {
let heading = numberOrDefault(value, 0) % 360;
if (heading > 180) {
heading -= 360;
}
if (heading <= -180) {
heading += 360;
}
return heading;
}
function vec3OrDefault(value, fallback) {
if (!Array.isArray(value) || value.length !== 3) {
return fallback;
}
return value.map((item, index) => numberOrDefault(item, fallback[index]));
}
function numberOrDefault(value, fallback) {
return Number.isFinite(value) ? value : fallback;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function degToRad(value) {
return (value * Math.PI) / 180;
}
function radToDeg(value) {
return (value * 180) / Math.PI;
}
function round(value) {
return Math.round(value * 1000000) / 1000000;
}