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

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ offline-deps/python-runtime/
dist/ dist/
**/dist/ **/dist/
web-test/public/cesium/ web-test/public/cesium/
service/output/
coverage/ coverage/
**/coverage/ **/coverage/
__pycache__/ __pycache__/

View File

@@ -0,0 +1,379 @@
# F-22 Cobra 机动研究记录
本文记录当前 JSBSim 眼镜蛇机动研究的现状、技术方案和已修改位置。范围覆盖 `service` 后端、`web-test` 前端、共享 schema以及为 F-22 Cobra 研究复制出的本地飞机模型。
## 目前现状
项目当前以 `web-test` 为前端、`service` 为后端,通过本地 HTTP API 调用 JSBSim 完成飞行动力学仿真。
当前默认研究对象是:
- 前端 aircraftId: `f22cobra-jsbsim`
- 后端 JSBSim model id: `f22cobra`
- 模型文件: `service/aircraft/f22cobra/f22cobra.xml`
- 后端服务: `http://127.0.0.1:4317`
- 内部积分频率: `integrationRateHz = 120`
- 输出采样频率: `sampleRateHz = 30`
当前 Cobra 默认参数是一个稳定的近似 Cobra 基线,而不是追求极限 120 度迎角的表演级参数:
```json
{
"aircraftId": "f22cobra-jsbsim",
"durationSec": 5,
"sampleRateHz": 30,
"integrationRateHz": 120,
"trim": false,
"initialState": {
"position": [0, 3000, 0],
"velocityMps": 120,
"headingDeg": 0
},
"maneuver": {
"type": "cobra",
"parameters": {
"controlMode": "closed-loop",
"entryDelaySec": 0.15,
"pullDurationSec": 0.7,
"holdDurationSec": 0,
"recoveryDurationSec": 2.0,
"targetAlphaDeg": 80,
"recoveryAlphaDeg": 8,
"pitchRateLimitDegS": 80,
"holdPitchRateDegS": 0,
"maxElevatorCmd": -0.45,
"recoveryElevatorCmd": 0.65,
"throttleCmd": 1,
"pullTvcCmd": -0.05,
"recoveryTvcCmd": 0.85,
"speedbrakeCmd": 0
}
}
}
```
在当前 F-22 模型和上述参数下,直接 JSBSim 验证的典型指标约为:
- 峰值迎角 `maxAlpha`: 约 `71.9 deg`
- 峰值俯仰角速度 `maxPitchRate`: 约 `122.9 deg/s`
- 最低速度 `minSpeed`: 约 `106.4 m/s`
- 5 秒末迎角 `endAlpha`: 约 `9.7 deg`
- 5 秒末俯仰角 `endPitch`: 约 `31.0 deg`
这个状态的意义是:先获得可重复、能恢复、不会突然翻转的基线,再继续向更高 alpha 推进。
## 技术方案
### 1. 后端用 JSBSim 做 6DoF 积分
后端 `service/scripts/jsbsim_runner.py` 是 JSBSim 的 Python runner。Node 后端通过 `service/src/backends/jsbsimBackend.js` 调用该 runner。
关键点:
- `sampleRateHz` 只控制返回给前端的采样密度。
- `integrationRateHz` 控制 JSBSim 内部积分步长,当前默认 120 Hz。
- 仿真过程中内部积分按 `integrationRateHz` 推进,输出样本按 `sampleRateHz` 抽样。
- F-22 研究默认 `trim: false`,因为 bundled F-22 在部分速度点纵向 trim 会失败,但直接 initial condition 可以运行。
### 2. 用本地 F-22 研究模型打开 TVC 控制
原始 bundled F-22 的 TVC 链路不适合直接外部命令控制。当前复制出本地模型:
```text
service/aircraft/f22cobra/f22cobra.xml
```
并加入/改造:
- `fcs/tvc-cmd-norm`
- TVC actuator 输入改为外部命令。
- 输出仍写到 `fcs/tvc-pos-norm` / `fcs/tvc-pos-rad`,供 JSBSim 气动和遥测读取。
这样后端可以通过 `fcs/tvc-cmd-norm` 直接施加 TVC 指令。
### 3. Cobra 从开环时间线升级为闭环控制器
早期 Cobra 使用固定时间线:
```text
某时刻 elevator = X, tvc = Y, throttle = Z
```
这种开环方式容易出现:
- 抬头过猛
- 俯仰角速度尖峰
- 高迎角后恢复不下来
- 轨迹出现不自然的旋转或深失速
现在 `cobra` 默认使用:
```json
{ "controlMode": "closed-loop" }
```
闭环控制器每个积分步读取当前状态:
- `aero/alpha-rad`
- `attitude/theta-rad`
- `velocities/q-rad_sec`
- `velocities/vtrue-fps`
然后计算:
- elevator command
- TVC command
- throttle command
- speedbrake command
控制目标不是“固定时间点打多少杆”,而是:
- 拉起阶段限制 pitch rate
- 接近目标 alpha 时自动减小拉起
- 恢复阶段用 elevator + TVC 把 alpha 拉回低迎角
- 避免过快掉速和深失速
旧的开环 Cobra 仍保留:
```json
{ "controlMode": "open-loop" }
```
这方便后续对比开环和闭环表现。
### 4. 姿态输出改用四元数
前端之前使用 Euler/HPR 处理姿态,在接近 90 度俯仰时容易出现视觉上的突然翻转。
现在后端输出 body-to-local ENU 四元数:
```json
"rotation": [x, y, z, w]
```
前端播放时用 quaternion slerp 插值Cesium 渲染也直接使用四元数路径。这避免了高俯仰角附近的 Euler 奇异跳变。
### 5. 轨迹积分使用 JSBSim 地速分量
位置轨迹不再用 heading/pitch 自己推算水平位移,而是使用 JSBSim 输出的地球坐标速度:
- `velocities/v-east-fps`
- `velocities/v-north-fps`
这避免了姿态大幅变化时轨迹方向和机体方向互相污染。
### 6. 前端加入 Cobra 参数面板和遥测面板
前端现在默认选中 `f22cobra-jsbsim``Cobra`
Cobra 参数面板可以直接调:
- `Target alpha`
- `Pitch-rate cap`
- `Pull time`
- `Recovery time`
- `Pull elevator`
- `Recovery elevator`
- `Pull TVC`
- `Recovery TVC`
右侧遥测面板显示关键曲线和 summary便于判断问题来源
- alpha
- pitch
- pitch rate
- velocity
- altitude delta
- elevator command
- TVC command
- speedbrake command
## 修改的地方
### service 后端
#### `service/scripts/jsbsim_runner.py`
主要改动:
- 新增 `integrationRateHz` 支持,默认 120 Hz。
- 内部积分频率与输出采样频率解耦。
- 新增/扩展 `cobra` maneuver。
- 新增 `controlMode = closed-loop` 的 Cobra 控制器。
- 保留 `controlMode = open-loop` 的时间线控制。
- 控制输入支持:
- elevator
- aileron
- rudder
- throttle
- tvc
- speedbrake
- 输出样本增加:
- `alphaDeg`
- `betaDeg`
- `qbar`
- `verticalSpeedMps`
- `pitchRateDegS`
- `rollRateDegS`
- `yawRateDegS`
- `controlInputs`
- `controlSurfaces`
- `fcs`
- 姿态输出改为 ENU body quaternion。
- 本地位置积分改用 JSBSim east/north velocity。
#### `service/aircraft/f22cobra/f22cobra.xml`
主要改动:
- 从 bundled JSBSim F-22 复制出项目本地研究模型。
- 模型名称改为 F-22 Cobra Research。
- 新增 `fcs/tvc-cmd-norm` property。
- TVC actuator 改为读取 `fcs/tvc-cmd-norm`
- 保证外部控制能驱动 `fcs/tvc-pos-norm`
#### `service/src/backends/jsbsimBackend.js`
主要改动:
- 新增 `f22cobra-jsbsim` aircraft。
-`integrationRateHz` 透传给 Python runner。
- `cobra` / `control-script` 加入 JSBSim 后端支持列表。
- F-22 研究机型标记为 high-alpha / TVC / no-trim experimental。
#### `service/src/backends/mockBackend.js`
主要改动:
- mock backend 同步支持 `integrationRateHz`
- mock 输出结构补齐部分 JSBSim 遥测字段。
- 支持 `control-script` / `cobra` 的视觉测试路径。
#### `service/src/simulator.test.js`
主要改动:
- 增加对 `integrationRateHz = 120` 的断言。
- 增加 `control-script` / `cobra` 基础测试。
#### `service/scripts/sweep_f22_cobra.py`
主要作用:
- 批量扫描 Cobra 参数组合。
- 计算 max alpha、max pitch、min speed、end alpha、end pitch、forward delta 等指标。
- 用 score 排序,辅助后续自动调参。
### shared schema
#### `shared/schemas/flight-sim-api.schema.json`
主要改动:
- 新增 `integrationRateHz`
- 新增 `trim`
- maneuver enum 增加:
- `control-script`
- `cobra`
- sample schema 增加遥测字段:
- `qbar`
- `verticalSpeedMps`
- `pitchRateDegS`
- `rollRateDegS`
- `yawRateDegS`
- `controlInputs`
- `controlSurfaces`
- `fcs`
- Cobra 参数说明中记录 `controlMode=open-loop|closed-loop`
### web-test 前端
#### `web-test/src/api/simulationService.js`
主要改动:
- 默认 aircraft 使用 F-22 Cobra research。
- 默认 `sampleRateHz = 30`
- 默认 `integrationRateHz = 120`
- F-22 research 默认 `trim = false`
- Cobra 默认参数改为稳定闭环基线。
- 支持从前端 Cobra 参数面板覆盖默认参数。
- 参数进入请求前做范围 clamp。
#### `web-test/index.html`
主要改动:
- 默认 maneuver 改为 Cobra。
- 默认 duration 改为 5 秒。
- 默认 speed 改为 120 m/s。
- 新增 Cobra Controller 参数面板。
- 新增遥测 canvas 面板。
#### `web-test/src/ui/controls.js`
主要改动:
- 读取 Cobra 参数面板输入。
- `getValues()` 返回 `cobraParameters`
- 非 Cobra 机动时隐藏 Cobra 参数区。
#### `web-test/src/ui/researchPanel.js`
主要作用:
- 绘制 Cobra 遥测曲线。
- 输出 summary
- max alpha
- max pitch
- max pitch rate
- min speed
- end alpha
- speedbrake 状态
#### `web-test/src/playback/trajectoryPlayer.js`
主要改动:
- 使用四元数 slerp 插值姿态。
- 减少高俯仰角播放时的姿态跳变。
#### `web-test/src/renderer/cesiumScene.js`
主要改动:
- 支持后端 quaternion rotation。
- Cesium aircraft orientation 从本地 ENU body quaternion 转换到世界坐标。
- 保留 HPR fallback。
#### `web-test/src/styles.css`
主要改动:
- 增加 Cobra 参数面板样式。
- 增加遥测面板样式。
- 调整移动端布局。
## 当前限制
1. 当前 F-22 XML 的高迎角 / 失速后气动可信度有限。
2. 目前稳定基线只能做到约 70-72 度 alpha继续推高 alpha 容易进入深失速或恢复困难。
3. `speedbrakeCmd` 在该 F-22 模型中更像离散开关,部分小数指令不会有效展开。
4. 目前闭环控制器是工程研究用,不是完整真实 FCS。
5. 参数 sweep 还没有接入前端,只能通过脚本跑。
## 下一步建议
1.`sweep_f22_cobra.py` 接到 service API支持前端一键 sweep。
2. 增加 Cobra 参数 preset
- stable baseline
- high-alpha attempt
- aggressive TVC recovery
- open-loop comparison
3. 为机动研究抽象统一 controller 接口,后续复用到:
- Herbst maneuver
- Kulbit
- J-turn
- tailslide
4. 进一步寻找或构建更可信的高迎角/失速后 F-22、Su-27、F-16 MATV 等 JSBSim 模型。
5. 增加自动评分指标,把“像不像 Cobra”量化而不是只靠视觉判断。

98
service/README.md Normal file
View File

@@ -0,0 +1,98 @@
# Local Flight Simulation Service
Offline mock service for the first milestone.
## Run
```powershell
npm run service
```
The default URL is:
```text
http://127.0.0.1:4317
```
## Endpoints
- `GET /health`
- `GET /aircraft`
- `POST /trim`
- `POST /simulate`
- `POST /cancel`
## Coordinate and Heading Conventions
- Local position is `[east, up, north]` in meters.
- True heading `0 deg` points north.
- Clockwise heading is positive.
- Counter-clockwise heading is negative.
## Rates
- `sampleRateHz` controls the output sample frequency returned to the web client.
- `integrationRateHz` controls the internal JSBSim integration frequency and defaults to `120`.
- The web client currently requests `sampleRateHz: 30` and `integrationRateHz: 120`.
- `trim: false` skips pre-simulation trim. This is useful for F-22 maneuver research because the bundled F-22 model can run from initial conditions even when longitudinal trim fails.
The mock service currently supports:
- `straight`
- `climb`
- `descent`
- `level-turn`
- `control-script`
- `cobra`
## Maneuver Research Path
This project treats special maneuvers as reusable control-input studies:
1. Use `control-script` for raw elevator/aileron/rudder/throttle timelines.
2. Build named maneuver templates, such as `cobra`, on top of those timelines.
3. Compare mock output against the JSBSim backend.
4. Improve or replace aircraft XML models when high-alpha/post-stall data is not credible.
`control-script` parameters:
```json
{
"timeline": [
{ "t": 0.0, "elevator": 0, "aileron": 0, "rudder": 0, "throttle": 0.85 },
{ "t": 0.5, "elevator": 0.8, "aileron": 0, "rudder": 0, "throttle": 1.0 },
{ "t": 1.6, "elevator": 0, "aileron": 0, "rudder": 0, "throttle": 1.0 }
]
}
```
`cobra` is currently an experimental template. It is useful for plumbing and visualization, but physical credibility depends on the selected JSBSim aircraft model having high-alpha and post-stall aerodynamic tables.
The default F-22 research path uses `f22cobra-jsbsim`, a project-local copy of the bundled F-22 model with manual TVC command support. The current stable near-cobra baseline uses the closed-loop Cobra controller:
```json
{
"aircraftId": "f22cobra-jsbsim",
"durationSec": 5,
"initialState": { "position": [0, 3000, 0], "velocityMps": 120, "headingDeg": 0 },
"trim": false,
"maneuver": {
"type": "cobra",
"parameters": {
"controlMode": "closed-loop",
"pullDurationSec": 0.7,
"recoveryDurationSec": 2.0,
"targetAlphaDeg": 80,
"pitchRateLimitDegS": 80,
"maxElevatorCmd": -0.45,
"recoveryElevatorCmd": 0.65,
"pullTvcCmd": -0.05,
"recoveryTvcCmd": 0.85,
"throttleCmd": 1,
"speedbrakeCmd": 0
}
}
}
```
This baseline reaches roughly 70-72 degrees alpha in the current model, keeps peak pitch rate around 120-125 deg/s, and recovers to low alpha by the end of the run. Higher-alpha open-loop settings can still be tested with `"controlMode": "open-loop"`, but they tend to deep-stall or over-rotate with the current F-22 aerodynamic tables.

File diff suppressed because it is too large Load Diff

15
service/package-lock.json generated Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "local-flight-sim-service",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "local-flight-sim-service",
"version": "0.1.0",
"engines": {
"node": ">=20"
}
}
}
}

14
service/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "local-flight-sim-service",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"test": "node src/simulator.test.js"
},
"engines": {
"node": ">=20"
}
}

View File

@@ -0,0 +1,587 @@
import json
import math
import os
import sys
import uuid
FT_PER_M = 3.280839895
M_PER_FT = 1 / FT_PER_M
KTS_PER_MPS = 1.943844492
MPS_PER_FPS = 0.3048
DEFAULT_INTEGRATION_RATE_HZ = 120
def main():
request = json.load(sys.stdin)
operation = request.get("operation")
if operation == "trim":
response = trim_initial_state(request)
elif operation == "simulate":
response = simulate(request)
else:
raise RuntimeError(f"Unsupported JSBSim runner operation: {operation}")
json.dump(response, sys.stdout)
def create_fdm(aircraft_id, integration_rate_hz):
try:
import jsbsim
except ImportError as error:
raise RuntimeError(
"Python package 'jsbsim' is not installed. Install it with: pip install jsbsim"
) from error
root_dir = os.environ.get("JSBSIM_ROOT")
fdm = jsbsim.FGFDMExec(root_dir) if root_dir else jsbsim.FGFDMExec(None)
fdm.set_debug_level(0)
fdm.disable_output()
fdm.set_dt(1.0 / integration_rate_hz)
if aircraft_id == "f22cobra":
package_root = os.path.dirname(jsbsim.__file__)
service_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
fdm.set_aircraft_path(os.path.join(service_root, "aircraft"))
fdm.set_engine_path(os.path.join(package_root, "engine"))
fdm.set_systems_path(os.path.join(package_root, "systems"))
elif root_dir:
fdm.set_aircraft_path(os.path.join(root_dir, "aircraft"))
fdm.set_engine_path(os.path.join(root_dir, "engine"))
fdm.set_systems_path(os.path.join(root_dir, "systems"))
if not fdm.load_model(aircraft_id):
raise RuntimeError(f"JSBSim could not load aircraft model: {aircraft_id}")
return fdm
def trim_initial_state(request):
integration_rate_hz = integration_rate_from_request(request)
aircraft_id = normalize_aircraft_id(request.get("aircraftId"))
trim_request = request.get("trim", request)
fdm = create_fdm(aircraft_id, integration_rate_hz)
altitude_m = number_or_default(trim_request.get("altitudeM"), 1000)
speed_mps = number_or_default(trim_request.get("speedMps"), 70)
heading_deg = number_or_default(trim_request.get("headingDeg"), 0)
gamma_deg = number_or_default(trim_request.get("flightPathAngleDeg"), 0)
configure_initial_conditions(
fdm,
altitude_m=altitude_m,
speed_mps=speed_mps,
heading_deg=heading_deg,
gamma_deg=gamma_deg,
)
run_trim(fdm)
return {
"success": True,
"aircraftId": request.get("aircraftId"),
"state": read_state(fdm, fallback_position=[0, altitude_m, 0]),
}
def simulate(request):
sample_rate_hz = max(1, min(240, number_or_default(request.get("sampleRateHz"), 30)))
integration_rate_hz = integration_rate_from_request(request)
integration_dt = 1.0 / integration_rate_hz
duration_sec = max(0.1, min(3600, number_or_default(request.get("durationSec"), 10)))
aircraft_id = normalize_aircraft_id(request.get("aircraftId"))
maneuver = request.get("maneuver") or {"type": "straight", "parameters": {}}
parameters = maneuver.get("parameters") or {}
initial_state = request.get("initialState") or {}
speed_mps = number_or_default(initial_state.get("velocityMps"), 70)
heading_deg = number_or_default(initial_state.get("headingDeg"), 0)
start_position = vector_or_default(initial_state.get("position"), [0, 1000, 0])
gamma_deg = maneuver_gamma_deg(maneuver.get("type"), parameters)
fdm = create_fdm(aircraft_id, integration_rate_hz)
configure_initial_conditions(
fdm,
altitude_m=start_position[1],
speed_mps=speed_mps,
heading_deg=heading_deg,
gamma_deg=gamma_deg,
)
if should_trim(request, parameters):
run_trim(fdm)
total_samples = int(math.floor(duration_sec * sample_rate_hz)) + 1
samples = []
east = start_position[0]
north = start_position[2]
sim_time = 0.0
for index in range(total_samples):
target_t = min(index / sample_rate_hz, duration_sec)
while sim_time < target_t - 1e-9:
step_dt = min(integration_dt, target_t - sim_time)
fdm.set_dt(step_dt)
controls = controls_at_time(maneuver, sim_time, fdm)
apply_controls(fdm, controls)
fdm.run()
state = read_state(fdm, fallback_position=[east, start_position[1], north])
east += get_prop(fdm, "velocities/v-east-fps", 0) * MPS_PER_FPS * step_dt
north += get_prop(fdm, "velocities/v-north-fps", 0) * MPS_PER_FPS * step_dt
sim_time += step_dt
state = read_state(fdm, fallback_position=start_position if index == 0 else [east, start_position[1], north])
altitude_m = get_prop(fdm, "position/h-sl-ft", start_position[1] * FT_PER_M) * M_PER_FT
state["position"] = [round_float(east), round_float(altitude_m), round_float(north)]
state["t"] = round_float(target_t)
samples.append(state)
return {
"id": f"sim_{uuid.uuid4()}",
"aircraftId": request.get("aircraftId"),
"sampleRateHz": sample_rate_hz,
"integrationRateHz": integration_rate_hz,
"durationSec": duration_sec,
"coordinateFrame": "local-eun",
"headingConvention": "true heading: 0 deg north, clockwise positive, counter-clockwise negative",
"samples": samples,
}
def configure_initial_conditions(fdm, altitude_m, speed_mps, heading_deg, gamma_deg):
set_prop(fdm, "ic/h-sl-ft", altitude_m * FT_PER_M)
set_prop(fdm, "ic/vc-kts", speed_mps * KTS_PER_MPS)
set_prop(fdm, "ic/gamma-deg", gamma_deg)
set_prop(fdm, "ic/psi-true-deg", heading_deg)
set_prop(fdm, "ic/lat-geod-deg", 0)
set_prop(fdm, "ic/long-gc-deg", 0)
set_prop(fdm, "ic/terrain-elevation-ft", 0)
set_prop(fdm, "propulsion/set-running", -1)
if not fdm.run_ic():
raise RuntimeError("JSBSim run_ic() failed.")
set_airborne_configuration(fdm)
def set_airborne_configuration(fdm):
set_prop(fdm, "gear/gear-cmd-norm", 0)
set_prop(fdm, "gear/gear-pos-norm", 0)
set_prop(fdm, "fcs/flap-cmd-norm", 0)
set_prop(fdm, "fcs/flap-pos-norm", 0)
set_prop(fdm, "fcs/speedbrake-cmd-norm", 0)
set_prop(fdm, "fcs/speedbrake-pos-norm", 0)
def run_trim(fdm):
try:
fdm.do_trim(0)
except Exception as error:
raise RuntimeError(f"JSBSim longitudinal trim failed: {error}") from error
def read_state(fdm, fallback_position):
phi = get_prop(fdm, "attitude/phi-rad", 0)
theta = get_prop(fdm, "attitude/theta-rad", 0)
psi = get_prop(fdm, "attitude/psi-rad", 0)
heading_deg = normalize_heading(math.degrees(psi))
altitude_m = get_prop(fdm, "position/h-sl-ft", fallback_position[1] * FT_PER_M) * M_PER_FT
velocity_mps = get_prop(fdm, "velocities/vtrue-fps", 0) * MPS_PER_FPS
vertical_speed_mps = get_prop(fdm, "velocities/h-dot-fps", 0) * MPS_PER_FPS
pitch_rate_deg_s = math.degrees(get_prop(fdm, "velocities/q-rad_sec", 0))
roll_rate_deg_s = math.degrees(get_prop(fdm, "velocities/p-rad_sec", 0))
yaw_rate_deg_s = math.degrees(get_prop(fdm, "velocities/r-rad_sec", 0))
return {
"position": [fallback_position[0], round_float(altitude_m), fallback_position[2]],
"rotation": euler_to_enu_body_quat(phi, theta, psi),
"velocityMps": round_float(velocity_mps),
"headingDeg": round_float(heading_deg),
"pitchDeg": round_float(math.degrees(theta)),
"rollDeg": round_float(math.degrees(phi)),
"alphaDeg": round_float(math.degrees(get_prop(fdm, "aero/alpha-rad", 0))),
"betaDeg": round_float(math.degrees(get_prop(fdm, "aero/beta-rad", 0))),
"loadFactor": round_float(get_prop(fdm, "accelerations/n-pilot-z-norm", 1)),
"mach": round_float(get_prop(fdm, "velocities/mach", 0)),
"qbar": round_float(get_prop(fdm, "aero/qbar-psf", 0)),
"verticalSpeedMps": round_float(vertical_speed_mps),
"pitchRateDegS": round_float(pitch_rate_deg_s),
"rollRateDegS": round_float(roll_rate_deg_s),
"yawRateDegS": round_float(yaw_rate_deg_s),
"throttle": round_float(get_prop(fdm, "fcs/throttle-cmd-norm", 0)),
"controlInputs": {
"elevator": round_float(get_prop(fdm, "fcs/elevator-cmd-norm", 0)),
"aileron": round_float(get_prop(fdm, "fcs/aileron-cmd-norm", 0)),
"rudder": round_float(get_prop(fdm, "fcs/rudder-cmd-norm", 0)),
"throttle": round_float(get_prop(fdm, "fcs/throttle-cmd-norm", 0)),
"tvc": round_float(get_prop(fdm, "fcs/tvc-cmd-norm", 0)),
"speedbrake": round_float(get_prop(fdm, "fcs/speedbrake-cmd-norm", 0)),
},
"controlSurfaces": {
"elevatorDeg": round_float(math.degrees(get_prop(fdm, "fcs/elevator-pos-rad", 0))),
"aileronDeg": round_float(math.degrees(get_prop(fdm, "fcs/aileron-pos-rad", 0))),
"rudderDeg": round_float(math.degrees(get_prop(fdm, "fcs/rudder-pos-rad", 0))),
"tvcDeg": round_float(math.degrees(get_prop(fdm, "fcs/tvc-pos-rad", 0))),
"speedbrakeNorm": round_float(get_prop(fdm, "fcs/speedbrake-pos-norm", 0)),
},
"fcs": {
"tvcPosNorm": round_float(get_prop(fdm, "fcs/tvc-pos-norm", 0)),
"tvcInhibit": round_float(get_prop(fdm, "fcs/tvc-inhibit", 0)),
"thrustNorm": round_float(get_prop(fdm, "fcs/thrust-norm", 0)),
"throttleOverride": round_float(get_prop(fdm, "fcs/throttle-override", 0)),
"pitchRateCmd": round_float(get_prop(fdm, "fcs/pitch-rate-cmd", 0)),
},
}
def maneuver_gamma_deg(maneuver_type, parameters):
if maneuver_type == "climb":
return abs(number_or_default(parameters.get("flightPathAngleDeg"), 8))
if maneuver_type == "descent":
return -abs(number_or_default(parameters.get("flightPathAngleDeg"), 8))
if maneuver_type == "straight":
return number_or_default(parameters.get("flightPathAngleDeg"), 0)
if maneuver_type in ("control-script", "cobra"):
return number_or_default(parameters.get("flightPathAngleDeg"), 0)
raise RuntimeError(
f"JSBSim minimal runner supports straight/climb/descent/control-script/cobra only. Unsupported maneuver.type: {maneuver_type}"
)
def controls_at_time(maneuver, t, fdm=None):
maneuver_type = maneuver.get("type")
parameters = maneuver.get("parameters") or {}
if maneuver_type == "cobra":
if parameters.get("controlMode", "closed-loop") == "closed-loop" and fdm is not None:
return cobra_closed_loop_controls(parameters, t, fdm)
timeline = cobra_timeline(parameters)
elif maneuver_type == "control-script":
timeline = parameters.get("timeline")
else:
return {
"elevator": 0,
"aileron": 0,
"rudder": 0,
"throttle": number_or_default(parameters.get("throttle"), 0.82),
"tvc": 0,
"speedbrake": 0,
}
if not isinstance(timeline, list) or len(timeline) == 0:
return {"elevator": 0, "aileron": 0, "rudder": 0, "throttle": 0.82, "tvc": 0, "speedbrake": 0}
points = sorted(
[point for point in timeline if isinstance(point, dict)],
key=lambda point: number_or_default(point.get("t"), 0),
)
if not points:
return {"elevator": 0, "aileron": 0, "rudder": 0, "throttle": 0.82}
if t <= number_or_default(points[0].get("t"), 0):
return control_point(points[0])
for index in range(1, len(points)):
previous = points[index - 1]
current = points[index]
previous_t = number_or_default(previous.get("t"), 0)
current_t = number_or_default(current.get("t"), previous_t)
if t <= current_t:
amount = 0 if current_t <= previous_t else (t - previous_t) / (current_t - previous_t)
return interpolate_controls(control_point(previous), control_point(current), amount)
return control_point(points[-1])
def cobra_closed_loop_controls(parameters, t, fdm):
entry_delay = max(0, number_or_default(parameters.get("entryDelaySec"), 0.15))
pull_duration = max(0.1, number_or_default(parameters.get("pullDurationSec"), 0.7))
hold_duration = max(0, number_or_default(parameters.get("holdDurationSec"), 0))
recovery_duration = max(0.1, number_or_default(parameters.get("recoveryDurationSec"), 2.0))
target_alpha = clamp(number_or_default(parameters.get("targetAlphaDeg"), 80), 45, 130)
recovery_alpha = clamp(number_or_default(parameters.get("recoveryAlphaDeg"), 8), 0, 35)
pitch_rate_limit = max(20, number_or_default(parameters.get("pitchRateLimitDegS"), 80))
hold_pitch_rate = number_or_default(parameters.get("holdPitchRateDegS"), 0)
max_pull_elevator = clamp(number_or_default(parameters.get("maxElevatorCmd"), -0.45), -1, 0)
max_recovery_elevator = clamp(number_or_default(parameters.get("recoveryElevatorCmd"), 0.65), 0, 1)
pull_tvc = clamp(number_or_default(parameters.get("pullTvcCmd"), -0.05), -1, 0)
recovery_tvc = clamp(number_or_default(parameters.get("recoveryTvcCmd"), 0.85), -1, 1)
throttle = clamp(number_or_default(parameters.get("throttleCmd"), 1), 0, 1)
speedbrake = clamp(number_or_default(parameters.get("speedbrakeCmd"), 0), 0, 1)
alpha = math.degrees(get_prop(fdm, "aero/alpha-rad", 0))
pitch = math.degrees(get_prop(fdm, "attitude/theta-rad", 0))
pitch_rate = math.degrees(get_prop(fdm, "velocities/q-rad_sec", 0))
speed_mps = get_prop(fdm, "velocities/vtrue-fps", 0) * MPS_PER_FPS
pull_end = entry_delay + pull_duration
hold_end = pull_end + hold_duration
recovery_end = hold_end + recovery_duration
if t < entry_delay:
return {
"elevator": 0,
"aileron": 0,
"rudder": 0,
"throttle": throttle,
"tvc": 0,
"speedbrake": 0,
}
if t < pull_end:
alpha_margin = target_alpha - alpha
alpha_scale = clamp(alpha_margin / 24, 0, 1)
rate_excess = max(0, pitch_rate - pitch_rate_limit)
rate_relief = clamp(rate_excess / pitch_rate_limit, 0, 1)
speed_relief = clamp((115 - speed_mps) / 45, 0, 0.35)
elevator = max_pull_elevator * alpha_scale + max_recovery_elevator * (rate_relief + speed_relief)
tvc = pull_tvc * alpha_scale
brake = speedbrake if alpha > 18 else 0
elif t < hold_end:
elevator = cobra_track_alpha(
target_alpha,
hold_pitch_rate,
alpha,
pitch_rate,
max_pull_elevator,
max_recovery_elevator,
)
tvc = pull_tvc * clamp((target_alpha - alpha) / 20, -0.4, 1)
brake = speedbrake
else:
recovery_amount = clamp((t - hold_end) / recovery_duration, 0, 1)
desired_alpha = target_alpha + (recovery_alpha - target_alpha) * smoothstep(recovery_amount)
desired_pitch_rate = 18 + (0 - 18) * recovery_amount
elevator = cobra_track_alpha(
desired_alpha,
desired_pitch_rate,
alpha,
pitch_rate,
max_pull_elevator * 0.55,
max_recovery_elevator,
)
if pitch > 70 and pitch_rate > 25:
elevator = max(elevator, max_recovery_elevator * 0.75)
tvc = recovery_tvc * clamp((alpha - desired_alpha) / 35, 0, 1)
brake = speedbrake if t < recovery_end and alpha > 35 else 0
return {
"elevator": clamp(elevator, -1, 1),
"aileron": 0,
"rudder": 0,
"throttle": throttle,
"tvc": clamp(tvc, -1, 1),
"speedbrake": brake,
}
def cobra_track_alpha(target_alpha, target_pitch_rate, alpha, pitch_rate, pull_limit, recovery_limit):
alpha_error = target_alpha - alpha
rate_error = pitch_rate - target_pitch_rate
elevator = -0.018 * alpha_error + 0.006 * rate_error
return clamp(elevator, pull_limit, recovery_limit)
def cobra_timeline(parameters):
pull_duration = max(0.1, number_or_default(parameters.get("pullDurationSec"), 0.9))
unload_duration = max(0.1, number_or_default(parameters.get("unloadDurationSec"), 0.7))
recovery_duration = max(0.1, number_or_default(parameters.get("recoveryDurationSec"), 2.0))
max_elevator = clamp(number_or_default(parameters.get("maxElevatorCmd"), -1.0), -1, 1)
recovery_elevator = clamp(number_or_default(parameters.get("recoveryElevatorCmd"), 0.35), -1, 1)
throttle = clamp(number_or_default(parameters.get("throttleCmd"), 1.0), 0, 1)
pull_tvc = clamp(number_or_default(parameters.get("pullTvcCmd"), 0), -1, 1)
recovery_tvc = clamp(number_or_default(parameters.get("recoveryTvcCmd"), 1), -1, 1)
speedbrake = clamp(number_or_default(parameters.get("speedbrakeCmd"), 0), 0, 1)
t0 = 0
t1 = t0 + 0.2
t2 = t1 + pull_duration
t3 = t2 + unload_duration
t4 = t3 + recovery_duration
return [
{"t": t0, "elevator": 0, "aileron": 0, "rudder": 0, "throttle": throttle, "tvc": 0, "speedbrake": 0},
{"t": t1, "elevator": max_elevator, "aileron": 0, "rudder": 0, "throttle": throttle, "tvc": pull_tvc, "speedbrake": speedbrake},
{"t": t2, "elevator": 0, "aileron": 0, "rudder": 0, "throttle": throttle, "tvc": pull_tvc, "speedbrake": speedbrake},
{"t": t3, "elevator": recovery_elevator, "aileron": 0, "rudder": 0, "throttle": throttle, "tvc": recovery_tvc, "speedbrake": speedbrake},
{"t": t4, "elevator": 0, "aileron": 0, "rudder": 0, "throttle": throttle, "tvc": 0, "speedbrake": 0},
]
def apply_controls(fdm, controls):
set_prop(fdm, "fcs/elevator-cmd-norm", clamp(controls.get("elevator"), -1, 1))
set_prop(fdm, "fcs/aileron-cmd-norm", clamp(controls.get("aileron"), -1, 1))
set_prop(fdm, "fcs/rudder-cmd-norm", clamp(controls.get("rudder"), -1, 1))
set_prop(fdm, "fcs/throttle-cmd-norm", clamp(controls.get("throttle"), 0, 1))
set_prop(fdm, "fcs/tvc-cmd-norm", clamp(controls.get("tvc"), -1, 1))
set_prop(fdm, "fcs/speedbrake-cmd-norm", clamp(controls.get("speedbrake"), 0, 1))
def control_point(point):
return {
"elevator": clamp(number_or_default(point.get("elevator"), 0), -1, 1),
"aileron": clamp(number_or_default(point.get("aileron"), 0), -1, 1),
"rudder": clamp(number_or_default(point.get("rudder"), 0), -1, 1),
"throttle": clamp(number_or_default(point.get("throttle"), 0.82), 0, 1),
"tvc": clamp(number_or_default(point.get("tvc"), 0), -1, 1),
"speedbrake": clamp(number_or_default(point.get("speedbrake"), 0), 0, 1),
}
def interpolate_controls(start, end, amount):
return {
"elevator": start["elevator"] + (end["elevator"] - start["elevator"]) * amount,
"aileron": start["aileron"] + (end["aileron"] - start["aileron"]) * amount,
"rudder": start["rudder"] + (end["rudder"] - start["rudder"]) * amount,
"throttle": start["throttle"] + (end["throttle"] - start["throttle"]) * amount,
"tvc": start["tvc"] + (end["tvc"] - start["tvc"]) * amount,
"speedbrake": start["speedbrake"] + (end["speedbrake"] - start["speedbrake"]) * amount,
}
def normalize_aircraft_id(aircraft_id):
if not aircraft_id:
return "c172p"
return aircraft_id.replace("-jsbsim", "")
def get_prop(fdm, name, fallback):
try:
return float(fdm.get_property_value(name))
except Exception:
try:
return float(fdm[name])
except Exception:
return fallback
def set_prop(fdm, name, value):
try:
fdm.set_property_value(name, value)
except Exception:
fdm[name] = value
def vector_or_default(value, fallback):
if not isinstance(value, list) or len(value) != 3:
return fallback
return [number_or_default(value[index], fallback[index]) for index in range(3)]
def number_or_default(value, fallback):
return value if isinstance(value, (int, float)) and math.isfinite(value) else fallback
def should_trim(request, parameters):
trim_value = request.get("trim", parameters.get("trim", True))
return trim_value is not False
def integration_rate_from_request(request):
return int(
max(
1,
min(
1000,
number_or_default(
request.get("integrationRateHz"),
DEFAULT_INTEGRATION_RATE_HZ,
),
),
)
)
def clamp(value, minimum, maximum):
return max(minimum, min(maximum, number_or_default(value, minimum)))
def smoothstep(value):
amount = clamp(value, 0, 1)
return amount * amount * (3 - 2 * amount)
def normalize_heading(value):
heading = value % 360
if heading > 180:
heading -= 360
if heading <= -180:
heading += 360
return heading
def euler_to_enu_body_quat(phi, theta, psi):
body_to_ned = body_to_ned_matrix(phi, theta, psi)
body_to_enu = [
[body_to_ned[1][0], body_to_ned[1][1], body_to_ned[1][2]],
[body_to_ned[0][0], body_to_ned[0][1], body_to_ned[0][2]],
[-body_to_ned[2][0], -body_to_ned[2][1], -body_to_ned[2][2]],
]
return matrix_to_quat(body_to_enu)
def body_to_ned_matrix(phi, theta, psi):
cphi = math.cos(phi)
sphi = math.sin(phi)
ctheta = math.cos(theta)
stheta = math.sin(theta)
cpsi = math.cos(psi)
spsi = math.sin(psi)
return [
[
ctheta * cpsi,
sphi * stheta * cpsi - cphi * spsi,
cphi * stheta * cpsi + sphi * spsi,
],
[
ctheta * spsi,
sphi * stheta * spsi + cphi * cpsi,
cphi * stheta * spsi - sphi * cpsi,
],
[-stheta, sphi * ctheta, cphi * ctheta],
]
def matrix_to_quat(matrix):
m00, m01, m02 = matrix[0]
m10, m11, m12 = matrix[1]
m20, m21, m22 = matrix[2]
trace = m00 + m11 + m22
if trace > 0:
scale = math.sqrt(trace + 1.0) * 2
w = 0.25 * scale
x = (m21 - m12) / scale
y = (m02 - m20) / scale
z = (m10 - m01) / scale
elif m00 > m11 and m00 > m22:
scale = math.sqrt(1.0 + m00 - m11 - m22) * 2
w = (m21 - m12) / scale
x = 0.25 * scale
y = (m01 + m10) / scale
z = (m02 + m20) / scale
elif m11 > m22:
scale = math.sqrt(1.0 + m11 - m00 - m22) * 2
w = (m02 - m20) / scale
x = (m01 + m10) / scale
y = 0.25 * scale
z = (m12 + m21) / scale
else:
scale = math.sqrt(1.0 + m22 - m00 - m11) * 2
w = (m10 - m01) / scale
x = (m02 + m20) / scale
y = (m12 + m21) / scale
z = 0.25 * scale
return [round_float(x), round_float(y), round_float(z), round_float(w)]
def round_float(value):
return round(value, 6)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(str(error), file=sys.stderr)
sys.exit(1)

View File

@@ -0,0 +1,260 @@
import argparse
import contextlib
import io
import itertools
import json
import math
import os
import sys
import jsbsim_runner
DEFAULT_AIRCRAFT_ID = "f22cobra"
DEFAULT_ALTITUDE_M = 3000
DEFAULT_SAMPLE_RATE_HZ = 20
DEFAULT_INTEGRATION_RATE_HZ = 120
DEFAULT_DURATION_SEC = 5
def main():
args = parse_args()
candidates = build_grid(args.mode)
results = []
print(
f"Running {len(candidates)} F-22 cobra candidates "
f"({args.mode}, duration={args.duration_sec}s)...",
file=sys.stderr,
)
for index, parameters in enumerate(candidates, start=1):
if index == 1 or index % 10 == 0 or index == len(candidates):
print(f"[{index}/{len(candidates)}]", file=sys.stderr)
result = run_candidate(parameters, args)
if result:
results.append(result)
ranked = sorted(results, key=lambda item: item["score"])
top = ranked[: args.top]
print_summary(top)
if args.json:
print(json.dumps(top, indent=2))
def parse_args():
parser = argparse.ArgumentParser(
description="Sweep F-22 JSBSim cobra maneuver parameters."
)
parser.add_argument(
"--mode",
choices=["quick", "wide"],
default="quick",
help="quick is small enough for iteration; wide explores more combinations.",
)
parser.add_argument("--top", type=int, default=10)
parser.add_argument("--duration-sec", type=float, default=DEFAULT_DURATION_SEC)
parser.add_argument("--json", action="store_true")
return parser.parse_args()
def build_grid(mode):
if mode == "wide":
entry_speeds = [180, 200, 220, 240, 260]
pull_durations = [0.45, 0.6, 0.75, 0.9, 1.05]
unload_durations = [0.35, 0.5, 0.7]
recovery_elevators = [0.2, 0.35, 0.5, 0.65]
recovery_tvc_cmds = [0.0, 0.35, 0.7, 1.0]
throttle_cmds = [0.85, 1.0]
else:
entry_speeds = [200, 220, 240]
pull_durations = [0.45, 0.6, 0.75, 0.9]
unload_durations = [0.35, 0.5, 0.7]
recovery_elevators = [0.25, 0.4, 0.55]
recovery_tvc_cmds = [0.0, 0.5, 1.0]
throttle_cmds = [1.0]
grid = []
for (
speed_mps,
pull_duration_sec,
unload_duration_sec,
recovery_elevator_cmd,
recovery_tvc_cmd,
throttle_cmd,
) in itertools.product(
entry_speeds,
pull_durations,
unload_durations,
recovery_elevators,
recovery_tvc_cmds,
throttle_cmds,
):
grid.append(
{
"entrySpeedMps": speed_mps,
"pullDurationSec": pull_duration_sec,
"unloadDurationSec": unload_duration_sec,
"recoveryDurationSec": 2.0,
"maxElevatorCmd": -1.0,
"recoveryElevatorCmd": recovery_elevator_cmd,
"pullTvcCmd": 0.0,
"recoveryTvcCmd": recovery_tvc_cmd,
"throttleCmd": throttle_cmd,
}
)
return grid
def run_candidate(parameters, args):
payload = {
"operation": "simulate",
"aircraftId": DEFAULT_AIRCRAFT_ID,
"sampleRateHz": DEFAULT_SAMPLE_RATE_HZ,
"integrationRateHz": DEFAULT_INTEGRATION_RATE_HZ,
"trim": False,
"durationSec": args.duration_sec,
"initialState": {
"position": [0, DEFAULT_ALTITUDE_M, 0],
"velocityMps": parameters["entrySpeedMps"],
"headingDeg": 0,
},
"maneuver": {
"type": "cobra",
"parameters": {
"flightPathAngleDeg": 0,
"pullDurationSec": parameters["pullDurationSec"],
"unloadDurationSec": parameters["unloadDurationSec"],
"recoveryDurationSec": parameters["recoveryDurationSec"],
"maxElevatorCmd": parameters["maxElevatorCmd"],
"recoveryElevatorCmd": parameters["recoveryElevatorCmd"],
"pullTvcCmd": parameters["pullTvcCmd"],
"recoveryTvcCmd": parameters["recoveryTvcCmd"],
"throttleCmd": parameters["throttleCmd"],
},
},
}
try:
with contextlib.redirect_stdout(io.StringIO()):
simulation = jsbsim_runner.simulate(payload)
except Exception as error:
return {
"score": 1_000_000,
"failed": True,
"error": str(error),
"parameters": parameters,
}
samples = simulation["samples"]
metrics = compute_metrics(samples, parameters)
return {
"score": round(score(metrics), 6),
"failed": False,
"parameters": parameters,
"metrics": metrics,
}
def compute_metrics(samples, parameters):
first = samples[0]
last = samples[-1]
max_alpha_sample = max(samples, key=lambda sample: sample.get("alphaDeg", -999))
max_pitch_sample = max(samples, key=lambda sample: sample.get("pitchDeg", -999))
min_speed_sample = min(samples, key=lambda sample: sample.get("velocityMps", 999999))
heading_delta = angle_delta_deg(first.get("headingDeg", 0), last.get("headingDeg", 0))
altitude_delta = last["position"][1] - first["position"][1]
forward_delta = last["position"][2] - first["position"][2]
lateral_delta = last["position"][0] - first["position"][0]
return {
"maxAlphaDeg": round_float(max_alpha_sample.get("alphaDeg", 0)),
"maxAlphaTimeSec": round_float(max_alpha_sample.get("t", 0)),
"maxPitchDeg": round_float(max_pitch_sample.get("pitchDeg", 0)),
"maxPitchTimeSec": round_float(max_pitch_sample.get("t", 0)),
"endAlphaDeg": round_float(last.get("alphaDeg", 0)),
"endPitchDeg": round_float(last.get("pitchDeg", 0)),
"minSpeedMps": round_float(min_speed_sample.get("velocityMps", 0)),
"endSpeedMps": round_float(last.get("velocityMps", 0)),
"altitudeDeltaM": round_float(altitude_delta),
"forwardDeltaM": round_float(forward_delta),
"lateralDeltaM": round_float(lateral_delta),
"headingDeltaDeg": round_float(heading_delta),
"entrySpeedMps": parameters["entrySpeedMps"],
"maxTvcDeg": round_float(
max(
abs(sample.get("controlSurfaces", {}).get("tvcDeg", 0))
for sample in samples
)
),
"maxThrustNorm": round_float(
max(sample.get("fcs", {}).get("thrustNorm", 0) for sample in samples)
),
}
def score(metrics):
max_alpha = metrics["maxAlphaDeg"]
max_pitch = metrics["maxPitchDeg"]
end_alpha = abs(metrics["endAlphaDeg"])
end_pitch = abs(metrics["endPitchDeg"])
altitude_delta = metrics["altitudeDeltaM"]
lateral_delta = abs(metrics["lateralDeltaM"])
speed_loss = metrics["entrySpeedMps"] - metrics["minSpeedMps"]
return (
abs(max_alpha - 105) * 2.0
+ abs(max_pitch - 95) * 1.2
+ max(0, 90 - max_alpha) * 8.0
+ max(0, max_alpha - 125) * 10.0
+ max(0, end_alpha - 25) * 1.5
+ max(0, end_pitch - 35) * 0.8
+ max(0, 35 - speed_loss) * 0.8
+ lateral_delta * 0.15
+ max(0, -altitude_delta) * 0.1
)
def print_summary(results):
print("")
print("rank score speed pull unload recElev recTvc maxA tA maxP endA endP minV dAlt dFwd dHead")
for index, result in enumerate(results, start=1):
params = result["parameters"]
metrics = result.get("metrics", {})
if result.get("failed"):
print(f"{index:>4} FAILED {result.get('error')}")
continue
print(
f"{index:>4} "
f"{result['score']:>6.1f} "
f"{params['entrySpeedMps']:>5.0f} "
f"{params['pullDurationSec']:>4.2f} "
f"{params['unloadDurationSec']:>5.2f} "
f"{params['recoveryElevatorCmd']:>7.2f} "
f"{params['recoveryTvcCmd']:>6.2f} "
f"{metrics['maxAlphaDeg']:>5.1f} "
f"{metrics['maxAlphaTimeSec']:>3.1f} "
f"{metrics['maxPitchDeg']:>5.1f} "
f"{metrics['endAlphaDeg']:>5.1f} "
f"{metrics['endPitchDeg']:>5.1f} "
f"{metrics['minSpeedMps']:>5.1f} "
f"{metrics['altitudeDeltaM']:>5.0f} "
f"{metrics['forwardDeltaM']:>5.0f} "
f"{metrics['headingDeltaDeg']:>5.1f}"
)
def angle_delta_deg(start, end):
return ((end - start + 540) % 360) - 180
def round_float(value):
return round(float(value), 6) if math.isfinite(float(value)) else value
if __name__ == "__main__":
main()

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;
}

View File

@@ -0,0 +1,27 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const envPath = resolve(serviceRoot, ".env.local");
if (existsSync(envPath)) {
const lines = readFileSync(envPath, "utf8").split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const equalsIndex = trimmed.indexOf("=");
if (equalsIndex <= 0) {
continue;
}
const key = trimmed.slice(0, equalsIndex).trim();
const value = trimmed.slice(equalsIndex + 1).trim();
if (!process.env[key]) {
process.env[key] = value;
}
}
}

116
service/src/server.js Normal file
View File

@@ -0,0 +1,116 @@
import "./loadLocalEnv.js";
import http from "node:http";
import {
AIRCRAFT,
getBackendInfo,
simulateManeuver,
trimInitialState,
} from "./simulator.js";
const PORT = Number.parseInt(process.env.PORT ?? "4317", 10);
const HOST = process.env.HOST ?? "127.0.0.1";
const SERVICE_NAME = "local-flight-sim";
const VERSION = "0.1.0";
const server = http.createServer(async (request, response) => {
try {
if (request.method === "OPTIONS") {
return sendJson(response, 204, null);
}
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${HOST}:${PORT}`}`);
if (request.method === "GET" && url.pathname === "/health") {
return sendJson(response, 200, {
ok: true,
service: SERVICE_NAME,
version: VERSION,
backend: getBackendInfo()
});
}
if (request.method === "GET" && url.pathname === "/aircraft") {
return sendJson(response, 200, { aircraft: AIRCRAFT });
}
if (request.method === "POST" && url.pathname === "/trim") {
const body = await readJson(request);
return sendJson(response, 200, trimInitialState(body));
}
if (request.method === "POST" && url.pathname === "/simulate") {
const body = await readJson(request);
return sendJson(response, 200, simulateManeuver(body));
}
if (request.method === "POST" && url.pathname === "/cancel") {
const body = await readJson(request);
return sendJson(response, 200, {
success: true,
id: body.id ?? null,
cancelled: false,
message: "No long-running simulation worker is active in the mock service."
});
}
return sendJson(response, 404, {
error: "not_found",
message: `${request.method} ${url.pathname} is not implemented.`
});
} catch (error) {
return sendJson(response, 400, {
error: "bad_request",
message: error instanceof Error ? error.message : String(error)
});
}
});
server.listen(PORT, HOST, () => {
console.log(`${SERVICE_NAME} ${VERSION} listening at http://${HOST}:${PORT}`);
});
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Content-Type": "application/json; charset=utf-8"
});
if (payload === null) {
response.end();
return;
}
response.end(JSON.stringify(payload, null, 2));
}
function readJson(request) {
return new Promise((resolve, reject) => {
let raw = "";
request.setEncoding("utf8");
request.on("data", (chunk) => {
raw += chunk;
if (raw.length > 1_000_000) {
reject(new Error("Request body is too large."));
request.destroy();
}
});
request.on("end", () => {
if (raw.trim() === "") {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch {
reject(new Error("Request body must be valid JSON."));
}
});
request.on("error", reject);
});
}

30
service/src/simulator.js Normal file
View File

@@ -0,0 +1,30 @@
import * as jsbsimBackend from "./backends/jsbsimBackend.js";
import * as mockBackend from "./backends/mockBackend.js";
const BACKENDS = {
mock: mockBackend,
jsbsim: jsbsimBackend,
};
const backendId = process.env.SIM_BACKEND ?? "mock";
const selectedBackend = BACKENDS[backendId] ?? mockBackend;
if (!BACKENDS[backendId]) {
console.warn(
`[simulator] Unknown SIM_BACKEND="${backendId}". Falling back to mock.`
);
}
export const AIRCRAFT = selectedBackend.AIRCRAFT;
export function getBackendInfo() {
return selectedBackend.backendInfo;
}
export function trimInitialState(request) {
return selectedBackend.trimInitialState(request);
}
export function simulateManeuver(request) {
return selectedBackend.simulateManeuver(request);
}

View File

@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
process.env.SIM_BACKEND = "mock";
import { getBackendInfo, simulateManeuver, trimInitialState } from "./simulator.js";
assert.equal(getBackendInfo().id, "mock");
const trim = trimInitialState({
aircraftId: "f16-mock",
altitudeM: 1000,
speedMps: 120,
headingDeg: 0
});
assert.equal(trim.success, true);
assert.deepEqual(trim.state.position, [0, 1000, 0]);
assert.equal(trim.state.headingDeg, 0);
const straight = simulateManeuver({
aircraftId: "f16-mock",
sampleRateHz: 10,
durationSec: 1,
initialState: {
position: [0, 1000, 0],
velocityMps: 100,
headingDeg: 0
},
maneuver: {
type: "straight",
parameters: {}
}
});
assert.equal(straight.samples.length, 11);
assert.equal(straight.integrationRateHz, 120);
assert.equal(straight.samples.at(-1).position[0], 0);
assert.equal(straight.samples.at(-1).position[2], 100);
const eastbound = simulateManeuver({
aircraftId: "f16-mock",
sampleRateHz: 10,
durationSec: 1,
initialState: {
position: [0, 1000, 0],
velocityMps: 100,
headingDeg: 90
},
maneuver: {
type: "straight",
parameters: {}
}
});
assert.equal(eastbound.samples.at(-1).position[0], 100);
assert.equal(eastbound.samples.at(-1).position[2], 0);
const leftTurn = simulateManeuver({
aircraftId: "f16-mock",
sampleRateHz: 1,
durationSec: 2,
initialState: {
position: [0, 1000, 0],
velocityMps: 120,
headingDeg: 0
},
maneuver: {
type: "level-turn",
parameters: {
turnRateDegPerSec: -10
}
}
});
assert.equal(leftTurn.samples.at(-1).headingDeg, -20);
const controlScript = simulateManeuver({
aircraftId: "f16-mock",
sampleRateHz: 10,
durationSec: 1,
initialState: {
position: [0, 1000, 0],
velocityMps: 100,
headingDeg: 0
},
maneuver: {
type: "control-script",
parameters: {
timeline: [
{ t: 0, elevator: 0, throttle: 0.8 },
{ t: 1, elevator: 1, throttle: 1 }
]
}
}
});
assert.equal(controlScript.samples.length, 11);
assert.equal(controlScript.samples.at(-1).controlInputs.elevator, 1);
assert.equal(controlScript.samples.at(-1).controlInputs.throttle, 1);
const cobra = simulateManeuver({
aircraftId: "f16-mock",
sampleRateHz: 10,
durationSec: 3,
initialState: {
position: [0, 1000, 0],
velocityMps: 170,
headingDeg: 0
},
maneuver: {
type: "cobra",
parameters: {}
}
});
assert.ok(Math.max(...cobra.samples.map((sample) => sample.alphaDeg)) > 60);
console.log("simulator tests passed");