diff --git a/.gitignore b/.gitignore index 84bc221..745f585 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ offline-deps/python-runtime/ dist/ **/dist/ web-test/public/cesium/ +service/output/ coverage/ **/coverage/ __pycache__/ diff --git a/service/F22_COBRA_RESEARCH.md b/service/F22_COBRA_RESEARCH.md new file mode 100644 index 0000000..5cbf4ac --- /dev/null +++ b/service/F22_COBRA_RESEARCH.md @@ -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”量化,而不是只靠视觉判断。 diff --git a/service/README.md b/service/README.md new file mode 100644 index 0000000..eb8a170 --- /dev/null +++ b/service/README.md @@ -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. diff --git a/service/aircraft/f22cobra/f22cobra.xml b/service/aircraft/f22cobra/f22cobra.xml new file mode 100644 index 0000000..d2cd60a --- /dev/null +++ b/service/aircraft/f22cobra/f22cobra.xml @@ -0,0 +1,2766 @@ + + + + + + Brian Mills + + 2009-05-24 + $Revision: 1.38 $ + Models an F-22A Raptor + + This model was created using data that is, or has been, publically + available by means of technical reports, textbooks, image graphs or + published code. This aircraft description file is in no way related + to the manufacturer of the real aircraft. + Neither the name of (any of) the authors nor the names of (any of) the + manufacturers may be used to endorse or promote products derived from + this file. + + The data is provided ''as is'' and any express or implied + warranties, including, but not limitted to the implied warranties of + merchantability and fitness for a particular purpose are disclaimed. + + + + + 840.0 + 44.49 + 23.06 + 238.9 + 18.66 + 158.2 + 0 + + 417.6 + 0 + 0 + + + 148.8 + 0 + 38 + + + 100 + 0 + 0 + + + + + 56005 + 280766 + 354090 + 713 + 43430 + + 445.7 + 0 + -18.6 + + + 230 + + 148.8 + 0 + 20 + + + + + + + + 96.7 + 0 + -89.3 + + 0.8 + 0.5 + 0.02 + 18603.4 + 6201.1 + 80 + NONE + 1 + + + + 468.8 + -48 + -89.3 + + 0.8 + 0.5 + 0.02 + 82011.2 + 22402.2 + 0.0 + LEFT + 1 + + + + 468.8 + 48 + -89.3 + + 0.8 + 0.5 + 0.02 + 82011.2 + 22402.2 + 0.0 + RIGHT + 1 + + + + + + 0 + 1 + + + 684.131 + -20 + 0 + + + 0.0 + 0.0 + 0.0 + + + + + 0 + 1 + + + 684.131 + 20 + 0 + + + 0.0 + 0.0 + 0.0 + + + + + + 446.478 + -36 + -18.6033 + + 9350 + 9350 + + + + 446.478 + 36 + -18.6033 + + 9350 + 9350 + + + + + + fcs/tvc-cmd-norm + + + + + + + + + velocities/u-fps + 0.3048 + + + + fcs/aileron-cmd-norm + fcs/roll-trim-cmd-norm + + -1 + 1 + + + + + + fcs/aileron-cmd-limiter + 10 + + + + + + + velocities/mach + aero/alpha-deg + position/h-sl-ft + + 0 0.2 0.4 0.6 0.8 1.0 1.2 1.4 1.6 + -90 0.6 0.6 0.6 0.6 0.6 0.6 0.6 0.6 0.6 + -25 0.6 0.6 0.6 0.6 0.6 0.6 0.6 0.6 0.6 + -20 0.6 0.85 1 1 1 1 1 1 1 + -15 0.6 0.85 1.5 1.5 1.5 1.5 1.5 1.5 1.5 + -10 0.6 1 2.5 3.14 3.14 3.14 3.14 2.617 2.1 + 0 0.6 1.5 2.5 3.66 4 4 3.14 2.617 2.1 + 10 0.6 1 2.5 3.14 3.14 3.14 3.14 2.617 2.1 + 15 0.6 0.85 2.5 2.617 2.617 2.617 2.617 2.617 2.1 + 20 0.6 0.85 2.1 2.1 2.1 2.1 2.1 2.1 2.1 + 25 0.6 0.85 1.5 1.5 1.5 1.5 1.5 1.5 1.5 + 30 0.6 0.85 1.3 1.3 1.3 1.3 1.3 1.3 1.3 + 35 0.6 0.85 1 1 1 1 1 1 1 + 40 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 + 45 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 + 180 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 + + + 0 0.2 0.4 0.6 0.8 1.0 1.2 1.4 1.6 + -90 0.6 0.6 0.6 0.6 0.6 0.6 0.6 0.6 0.6 + -25 0.6 0.6 0.6 0.6 0.6 0.6 0.6 0.6 0.6 + -20 0.6 0.85 1 1 1 1 1 1 1 + -15 0.6 0.85 1.5 1.5 1.5 1.5 1.5 1.5 1.5 + -10 0.6 0.85 2.5 3.14 3.14 3.14 3.14 2.617 2.1 + 0 0.6 0.85 2.5 3.66 4 4 3.14 2.617 2.1 + 10 0.6 0.85 2.5 3.14 3.14 3.14 3.14 2.617 2.1 + 15 0.6 0.85 2.5 2.617 2.617 2.617 2.617 2.617 2.1 + 20 0.6 0.85 2.1 2.1 2.1 2.1 2.1 2.1 2.1 + 25 0.6 0.85 1.5 1.5 1.5 1.5 1.5 1.5 1.5 + 30 0.6 0.85 1.3 1.3 1.3 1.3 1.3 1.3 1.3 + 35 0.6 0.85 1 1 1 1 1 1 1 + 40 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 + 45 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 + 180 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 + + + 0 0.2 0.4 0.6 0.8 1.0 1.2 1.4 1.6 + -90 0.6 0.6 0.6 0.6 0.6 0.6 0.6 0.6 0.6 + -25 0.6 0.6 0.6 0.6 0.6 0.6 0.6 0.6 0.6 + -20 0.6 0.85 1 1 1 1 1 1 1 + -15 0.6 0.85 1 1.25 1.5 1.5 1.5 1.5 1.5 + -10 0.6 0.85 1.5 1.5 2 2.25 2.25 2.25 2 + 0 0.6 0.85 1.5 2 2.5 2.5 2.75 2.75 2.5 + 10 0.6 0.85 1.5 1.5 2 2.25 2.25 2.25 2 + 15 0.6 0.85 1.5 2 2 2 2 2 2 + 20 0.6 0.85 1 1.25 1.5 2 2 2 2 + 25 0.6 0.85 1 1.25 1.5 1.5 1.5 1.5 1.5 + 30 0.6 0.85 1 1 1.3 1.3 1.3 1.3 1.3 + 35 0.6 0.85 1 1 1 1 1 1 1 + 40 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 + 45 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 + 180 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 + +
+
+
+ + + + fcs/roll-cmd-filter + fcs/roll-cmd-limit + + -4 + 4 + + + + + fcs/roll-rate-integrator + 8 + + -4 + 4 + + + + + fcs/roll-rate-cmd + -velocities/p-aero-rad_sec + -fcs/roll-windup-trigger + + + + + fcs/roll-rate-error + 1 + + -4 + 4 + + + + + + velocities/v-fps + 0.3048 + + + + fcs/rudder-cmd-norm + 1.0 + + -1 + 1 + + + + + + fcs/rudder-cmd-limiter + 10 + + + + + + + velocities/mach + position/h-sl-ft + + 0 36000 + 0 -0.436 -0.436 + 0.4 -0.349 -0.349 + 0.8 -0.1745 -0.1745 + 1.0 -0.1 -0.1 + 2.0 -0.1 -0.1 + +
+
+
+ + + fcs/rudder-cmd-filter + fcs/yaw-rate-limit + + + + + velocities/r-aero-rad_sec + 1.2 + + + + fcs/yaw-rate-integrator + 0.872 + + -0.436 + 0.436 + + + + + -fcs/yaw-rate-filter + -fcs/yaw-windup-trigger + fcs/yaw-rate-cmd + + + + + fcs/yaw-rate-error + 1 + + -0.436 + 0.436 + + fcs/yaw-rate-integrator + + + + + + + velocities/r-aero-rad_sec + velocities/mach + + 0 0.4 0.8 1.0 2.0 + -0.440 0.872 0.7 0.35 0.2 0.2 + -0.436 0 0.7 0.35 0.2 0.2 + -0.360 0 0.7 0.35 0.2 0.2 + -0.349 0 0 0.35 0.2 0.2 + -0.180 0 0 0.35 0.2 0.2 + -0.1745 0 0 0 0.2 0.2 + -0.11 0 0 0 0.2 0.2 + -0.1 0 0 0 0 0 + 0 0 0 0 0 0 + 0.1 0 0 0 0 0 + 0.11 0 0 0 -0.2 -0.2 + 0.1745 0 0 0 -0.2 -0.2 + 0.18 0 0 -0.35 -0.2 -0.2 + 0.349 0 0 -0.35 -0.2 -0.2 + 0.36 0 -0.7 -0.35 -0.2 -0.2 + 0.436 0 -0.7 -0.35 -0.2 -0.2 + 0.440 -0.872 -0.7 -0.35 -0.2 -0.2 + +
+
+
+ + + fcs/yaw-rate-integrator + fcs/yaw-rate-override + + + + + + + + + velocities/vc-kts + position/h-sl-ft + + 0 33000 50000 + 160 0.5 0.35 0.3 + 270 0.5 0.35 0.3 + 400 0.45 0.4 0.3 + 500 0.45 0.4 0.35 + 580 0.5 0.45 0.35 + 1000 0.5 0.45 0.4 + +
+ fcs/roll-rate-integrator + -1 + + velocities/vc-kts + position/h-sl-ft + + 0 33000 50000 + 160 -28.4059 -31.1627 -31.1627 + 270 -30.9146 -32.6575 -32.6575 + 330 -35.9146 -32.6575 -32.6575 + 400 -44.4687 -38.7651 -38.7651 + 580 -44.2746 -36.5662 -36.5662 + 700 -39.0735 -34.9842 -34.9842 + +
+
+ + fcs/v-velocity-norm + 0 + + velocities/vc-kts + position/h-sl-ft + + 0 33000 50000 + 160 -0.0494 -0.0266 -0.0266 + 270 0.0067 -0.0160 -0.0160 + 330 0.0067 -0.0160 -0.0160 + 400 -0.1082 -0.0108 -0.0108 + 580 -0.0104 -0.0091 -0.0091 + 700 -0.0095 -0.0118 -0.0118 + +
+
+ + + velocities/p-aero-rad_sec + -.35 + + velocities/vc-kts + position/h-sl-ft + + 0 33000 50000 + 160 11.7952 13.4573 13.4573 + 270 11.9041 13.1381 13.1381 + 330 11.9041 13.1381 13.1381 + 400 12.0644 10.6808 10.6808 + 580 11.0366 10.7511 10.7511 + 700 9.3909 10.8556 10.8556 + +
+
+
+ +
+ + -5 + 5 + +
+ + + fcs/roll-cmd + + -5 + 5 + + + -1 + 1 + + + + + fcs/roll-reg-scale + 0.00 + 2.29 + + -1 + 1 + + + + + fcs/aileron-act + fcs/speedbrake-aileron + + -1 + 1 + + fcs/left-aileron-pos-norm + + + + fcs/aileron-act + + -0.436 + 0.436 + + fcs/left-aileron-pos-rad + + + + fcs/aileron-act + -1 + + + + fcs/left-aileron-neg + fcs/speedbrake-aileron-right + + -1 + 1 + + fcs/right-aileron-pos-norm + + + + fcs/left-aileron-neg + + -0.436 + 0.436 + + fcs/right-aileron-pos-rad + + + + + + + + + fcs/yaw-cmd-summer + -0.2 + + velocities/vc-kts + position/h-sl-ft + + 0 33000 50000 + 160 22.1027 22.1741 22.1741 + 270 22.1775 15.7518 15.7518 + 400 14.0894 14.0894 14.0894 + 580 12.8629 12.8629 12.8629 + 700 14.8347 12.8631 12.8631 + +
+
+ + fcs/yaw-rate-filter + -0.2 + + velocities/vc-kts + position/h-sl-ft + + 0 33000 50000 + 160 -21.4844 -29.9948 -29.9948 + 270 -19.5573 -17.5259 -17.5259 + 400 -13.6930 -13.6930 -13.6930 + 580 -9.6423 -9.6423 -9.6423 + 700 -7.7903 -6.1197 -6.1197 + +
+
+
+ + velocities/vc-kts + position/h-sl-ft + + 0 33000 50000 + 160 1 1.2 1.2 + 270 1 1.1 1.1 + 400 0.5 0.55 0.6 + 580 0.3 0.35 0.4 + 700 0.2 0.25 0.3 + +
+ + aero/alpha-deg + position/h-sl-ft + + 0 33000 50000 + -60 3 3.5 4 + -40 2 2.5 3 + -20 1.5 1.75 2 + 0 0.7 0.8 0.9 + 20 1.75 2 2.5 + 40 2.5 3 3.5 + 60 3 3.5 4 + 80 3.5 4 5 + +
+
+
+ + -5 + 5 + +
+ + + fcs/yaw-cmd + + -5 + 5 + + + -1 + 1 + + + + + fcs/yaw-reg-scale + 0 + 2.673 + + + + -1 + 1 + + + + + fcs/rudder-act + fcs/speedbrake-rudder + + -1 + 1 + + fcs/rudder-pos-norm + + + + fcs/rudder-act + + -0.5236 + 0.5236 + + fcs/rudder-pos-rad + +
+ + + + + + + + + attitude/pitch-rad + + + attitude/roll-rad + + + + + + + accelerations/n-pilot-z-norm + -fcs/n-pilot-z-correction + + + + fcs/g-load-corrected + -1.0 + + + + + fcs/elevator-cmd-norm + 10 + + + + + + + gear/gear-pos-norm gt 0 + + fcs/tvc-inhibit + + + + fcs/stick-filter + fcs/pitch-trim-cmd-norm + + -1 + 0.6 + + + + + + + + velocities/mach + position/h-sl-ft + + 0 30000 60000 + 0 0.7 0.5236 0.349 + 0.2 0.8727 0.5236 0.349 + 0.4 0.8727 0.5236 0.349 + 0.6 0.8727 0.7 0.349 + 0.8 0.8727 0.7 0.349 + 1.0 0.7845 0.7 0.4363 + 1.2 0.611 0.611 0.4363 + 1.4 0.611 0.5236 0.349 + 1.6 0.5236 0.4363 0.349 + 1.8 0.4363 0.4363 0.349 + 2.0 0.349 0.349 0.349 + +
+
+
+ + + fcs/elevator-cmd-limiter + -fcs/pitch-cmd-limiter + + + + + + + velocities/mach + fcs/g-load-norm + + 0 0.4 0.8 1.0 1.2 1.4 1.6 1.8 2.0 + -3.75 0 0 0 0 0 0 0 0 0 + -3.5 1 1 1 1 0 0 0 0 0 + -3.25 1 1 1 1 1 1 0 0 0 + -3.0 1 1 1 1 1 1 1 1 1 + 0 1 1 1 1 1 1 1 1 1 + 7 1 1 1 1 1 1 1 1 1 + 7.25 1 1 1 1 1 -0.1 -0.1 -0.1 -0.1 + 7.5 1 1 1 1 1 -0.1 -0.1 -0.1 -0.1 + 7.75 1 1 1 1 -0.1 -0.1 -0.1 -0.1 -0.1 + 8.0 1 1 1 1 -0.1 -0.1 -0.1 -0.1 -0.1 + 8.25 1 1 1 1 -0.1 -0.1 -0.1 -0.1 -0.1 + 8.5 1 1 1 1 -0.1 -0.1 -0.1 -0.1 -0.1 + 8.75 1 1 1 1 -0.1 -0.1 -0.1 -0.1 -0.1 + 9.0 1 1 1 1 -0.1 -0.1 -0.1 -0.1 -0.1 + 9.25 1 1 1 1 -0.1 -0.1 -0.1 -0.1 -0.1 + 9.5 1 1 1 1 -0.1 -0.1 -0.1 -0.1 -0.1 + 9.75 -0.1 -0.1 -0.1 -0.1 -0.1 -0.1 0 0 0 + 10.0 -1 -1 -1 -1 -1 -1 -1 -1 -1 + +
+
+
+ + + fcs/pitch-rate-cmd + fcs/pitch-cmd-g-limiter + + -0.5236 + 0.8727 + + + + + + + + gear/gear-pos-norm gt 0 + + + + + fcs/g-limiter + fcs/gear-down-q-limit + + -0.5236 + 0.8727 + + + + + fcs/pitch-rate-integrator + 1.4 + + -0.5236 + 0.8727 + + + + + fcs/q-gear-down-limiter + -velocities/q-aero-rad_sec + -fcs/pitch-windup-trigger + + + + + fcs/pitch-rate-error + 1 + + -0.5236 + 0.8727 + + fcs/pitch-rate-integrator + + + + fcs/pitch-rate-integrator + + + + + + velocities/w-fps + 0.3048 + + + + + + + + + velocities/vc-kts + aero/alpha-deg + + 19 25 35 + 90 1 1 1 + 180 1 1 1 + 200 1 1 1 + 215 1 1 1 + 230 1 1 1 + 380 1 1 1 + 500 1 1 1 + 900 1 1 1 + +
+ + fcs/pitch-cmd-summer + -0.5 + + velocities/vc-kts + aero/alpha-deg + + 19 25 35 + 90 38.8840 25.9906 25.9906 + 180 38.8840 25.9906 25.9906 + 200 38.8840 25.9906 25.9906 + 215 58.8840 25.9906 25.9906 + 230 81.9605 58.8840 25.9906 + 380 63.4645 56.8517 34.4889 + 500 61.1371 58.5955 33.0386 + 900 48.8597 42.1147 42.1147 + +
+
+ + fcs/w-velocity-norm + 0 + + velocities/vc-kts + aero/alpha-deg + + 19 25 35 + 90 -0.0231 -0.0119 -0.0119 + 180 -0.0231 -0.0119 -0.0119 + 200 -0.0231 -0.0119 -0.0119 + 215 -0.0231 -0.0119 -0.0119 + 230 -0.0406 -0.0231 -0.0119 + 380 -0.0244 -0.0195 -0.0159 + 500 -0.0115 -0.0085 -0.0061 + 900 -0.0045 -0.0029 -0.0029 + +
+
+ + + velocities/vc-kts + aero/alpha-deg + + 19 25 35 + 90 1 1 1 + 180 1 1 1 + 200 1 1 1 + 215 1 1 1 + 230 1 1 1 + 380 1 1 1 + 500 1 1 1 + 900 1 1 1 + +
+ velocities/q-aero-rad_sec + -0.3 + + velocities/vc-kts + aero/alpha-deg + + 19 25 35 + 90 -34.0297 -19.6316 -19.6316 + 180 -34.0297 -19.6316 -19.6316 + 200 -34.0297 -19.6316 -19.6316 + 215 -44.0297 -19.6316 -19.6316 + 230 -62.1151 -44.0297 -19.6316 + 380 -47.3350 -42.1836 -26.0436 + 500 -45.4195 -43.0593 -24.6616 + 900 -35.7627 -30.5733 -30.5733 + +
+
+
+
+ + -5 + 5 + +
+ + + fcs/el-pitch-cmd + + -5 + 5 + + + -1 + 1 + + + + + fcs/el-reg-scale + 0.0 + 2 + + -1 + 1 + + fcs/elevator-pos-norm + + + + fcs/elevator-pos-norm + + -0.5236 + 0.5236 + + fcs/elevator-pos-rad + + + + -fcs/elevator-pos-rad + -fcs/left-aileron-pos-rad + + -0.5236 + 0.5236 + + + + + fcs/elevator-pos-rad + fcs/right-aileron-pos-rad + + -0.5236 + 0.5236 + + + + + + + + fcs/throttle-pos-norm ge 0.99 + + + + + + + + + + + + velocities/vc-kts + aero/alpha-deg + + 12 20 30 60 + 70 0.4 0.4 0.3 0.3 + 140 0.4 0.4 0.3 0.3 + 200 0.4 0.45 0.35 0.3 + 215 0.4 0.45 0.35 0.3 + 230 0.4 0.45 0.35 0.3 + 330 0.4 0.45 0.35 0.3 + 380 0.4 0.4 0.4 0.4 + 500 0.4 0.4 0.4 0.4 + 900 0.4 0.4 0.4 0.4 + +
+ fcs/pitch-cmd-summer + -1 + + velocities/vc-kts + aero/alpha-deg + position/h-sl-ft + + 12 20 30 + 70 97.6705 95.6705 95.6705 + 140 97.6705 95.6705 95.6705 + 200 97.6705 95.6705 95.6705 + 215 67.9777 95.6705 95.6705 + 230 0 67.9777 95.6705 + 330 0 0 0 + 380 0 0 0 + 500 0 0 0 + 900 0 0 0 + + + 12 20 30 + 70 97.6705 95.6705 95.6705 + 140 97.6705 95.6705 95.6705 + 200 97.6705 95.6705 95.6705 + 215 67.9777 95.6705 95.6705 + 230 0 67.9777 95.6705 + 330 0 49.6594 70.1697 + 380 0 34.2680 34.2680 + 500 0 0 0 + 900 0 0 0 + + + 12 20 30 + 70 97.6705 95.6705 95.6705 + 140 97.6705 95.6705 95.6705 + 200 97.6705 95.6705 95.6705 + 215 67.9777 95.6705 95.6705 + 230 0 67.9777 95.6705 + 330 0 56.0773 75.1918 + 380 0 56.0773 75.1918 + 500 0 49.6594 70.1697 + 900 0 34.2680 34.2680 + +
+
+ + fcs/w-velocity-norm + -0 + + velocities/vc-kts + aero/alpha-deg + position/h-sl-ft + + 12 20 30 + 70 -0.0444 -0.0444 -0.0444 + 140 -0.0444 -0.0444 -0.0444 + 200 -0.0444 -0.0444 -0.0444 + 215 -0.0271 -0.0444 -0.0444 + 230 0 -0.0271 -0.0444 + 330 0 0 0 + 380 0 0 0 + 500 0 0 0 + 900 0 0 0 + + + 12 20 30 + 70 -0.0444 -0.0444 -0.0444 + 140 -0.0444 -0.0444 -0.0444 + 200 -0.0444 -0.0444 -0.0444 + 215 -0.0271 -0.0444 -0.0444 + 230 0 -0.0271 -0.0444 + 330 0 -0.0073 -0.0130 + 380 0 -0.0024 -0.0024 + 500 0 0 0 + 900 0 0 0 + + + 12 20 30 + 70 -0.0444 -0.0444 -0.0444 + 140 -0.0444 -0.0444 -0.0444 + 200 -0.0444 -0.0444 -0.0444 + 215 -0.0271 -0.0444 -0.0444 + 230 0 -0.0271 -0.0444 + 330 0 -0.0195 -0.0349 + 380 0 -0.0195 -0.0349 + 500 0 -0.0073 -0.0130 + 900 0 -0.0024 -0.0024 + +
+
+ + + velocities/vc-kts + aero/alpha-deg + + 12 20 30 + 70 0.2 0.2 0.2 + 140 0.2 0.2 0.2 + 200 0.2 0.2 0.2 + 215 0.2 0.2 0.2 + 230 0.225 0.2 0.2 + 330 0.225 0.225 0.2 + 380 0.225 0.225 0.2 + 500 0.225 0.225 0.2 + 900 0.225 0.225 0.2 + +
+ velocities/q-aero-rad_sec + -1 + + velocities/vc-kts + aero/alpha-deg + position/h-sl-ft + + 12 20 30 + 70 -72.1205 -72.1205 -72.1205 + 140 -72.1205 -72.1205 -72.1205 + 200 -72.1205 -72.1205 -72.1205 + 215 -50.7416 -72.1205 -72.1205 + 230 0 -50.7416 -72.1205 + 330 0 0 0 + 380 0 0 0 + 500 0 0 0 + 900 0 0 0 + + + 12 20 30 + 70 -72.1205 -72.1205 -72.1205 + 140 -72.1205 -72.1205 -72.1205 + 200 -72.1205 -72.1205 -72.1205 + 215 -50.7416 -72.1205 -72.1205 + 230 0 -50.7416 -72.1205 + 330 0 -36.1358 -51.7437 + 380 0 -24.8715 -24.8715 + 500 0 0 0 + 900 0 0 0 + + + 12 20 30 + 70 -72.1205 -72.1205 -72.1205 + 140 -72.1205 -72.1205 -72.1205 + 200 -72.1205 -72.1205 -72.1205 + 215 -50.7416 -72.1205 -72.1205 + 230 0 -50.7416 -72.1205 + 330 0 -41.5043 -56.5934 + 380 0 -41.5043 -56.5934 + 500 0 -36.1358 -51.7437 + 900 0 -24.8715 -24.8715 + +
+
+
+ 1 +
+
+ + -5 + 5 + +
+ + + + fcs/tvc-pitch-cmd + fcs/tvc-inhibit + + + + + fcs/tvc-gain + 1 + + + + + + + + + + 7.0626 + fcs/pitch-rate-integrator + + + -0.0019 + fcs/u-velocity-norm + + + 0.0083 + fcs/w-velocity-norm + + + -5.1005 + velocities/q-aero-rad_sec + + + + velocities/mach + position/h-sl-ft + + 45000 50000 + 0.92 0 0 + 0.95 0 1.5 + 1.00 0 2.5 + 1.10 0 3.5 + 1.15 0 4 + 1.20 0 2.5 + 1.25 0 2 + 1.30 0 1.5 + 1.35 0 1.5 + 1.40 0 1 + 1.45 0 0.9 + 1.50 0 0.75 + 1.55 0 0.7 + 1.60 0 0.65 + 1.7 0 0.6 + 1.8 0 0.6 + 1.9 0 0.6 + 2.0 0 0.6 + +
+
+
+ + -0.7 + 0.05 + +
+ + + + + + velocities/q-aero-rad_sec + + -0.15 0 + -0.1 1 + 0.0 1 + 0.1 1 + 0.15 0 + +
+
+
+ + + + fcs/ss-trim + fcs/ss-trim-override + + + + fcs/tvc-gain-hi-lo + fcs/ss-trim-gain + + + + fcs/tvc-summer + + -5 + 5 + + + -1 + 1 + + + + + fcs/tvc-cmd-norm + 1 + + -1 + 1 + + fcs/tvc-pos-norm + + + + fcs/tvc-pos-norm + + -0.349 + 0.349 + + fcs/tvc-pos-rad + + +
+ + + + + aero/alpha-deg + 1.38 + + + + fcs/lef-alpha-gain + 2 + 7.25 + 1 + 7.25 + + + + + leading edge flap control function + + fcs/lef-alpha-filter + + + aero/qbar-psf + atmosphere/P-psf + + -9.05 + + 1.45 + + + + + + fcs/lef-transfer-function + 0.01745 + + -0.001 + 0.6108 + + + + + + + gear/wow eq 1 + gear/gear-pos-norm gt 0 + + + gear/gear-pos-norm == 0 + + + velocities/mach gt 0.9 + + + + + fcs/lef-pos + 1.637197 + + + + fcs/lef-norm + + + -1.0 + + + + 1.0 + + + + fcs/lef-pos-norm + + + + fcs/lef-control + + -1.0 + 1.0 + + + 0 + 35 + + fcs/lef-pos-deg + + + + fcs/lef-control + + -1.0 + 1.0 + + + 0 + 0.6108 + + fcs/lef-pos-rad + + + + + + + + + + + aero/alpha-deg + velocities/vc-kts + + 0 20 30 40 50 + 0 0.85 0.9 0.98 1 1 + 50 0.8 0.85 0.95 1 1 + 60 0.75 0.65 0.93 1 1 + 70 0.7 0.55 0.9 0.98 1 + 80 0.65 0.5 0.85 0.98 1 + 90 0.6 0.7 0.8 0.95 1 + 100 0.55 0.65 0.75 0.85 1 + 110 0.4 0.5 0.6 0.65 1 + 120 0.2 0.3 0.35 0.35 1 + 130 0.1 0.2 0.25 0.25 0.5 + 1000 0.1 0.1 0.2 0.25 0.5 + +
+
+
+ + + fcs/throttle-adjust + fcs/tvc-inhibit + fcs/throttle-override + + + + + fcs/throttle-cmd-norm + 1 + + + + fcs/throttle-filter + + + fcs/throttle-override + 1 + + + + + fcs/t1-summer + 1.001 + fcs/throttle-pos-norm + + + + + fcs/throttle-cmd-norm[1] + 1 + + + + fcs/throttle1-filter + + + fcs/throttle-override + 1 + + + + + fcs/t2-summer + 1.001 + fcs/throttle-pos-norm[1] + + + + + + + + propulsion/engine/thrust-lbs + 26550 + + + + 0 + 1.5 + + fcs/thrust-norm + + +
+ + + + + + + velocities/vc-kts lt 180 + velocities/mach lt 0.4 + + + velocities/vc-kts lt 200 + velocities/mach lt 0.45 + + + velocities/vc-kts lt 225 + velocities/mach lt 0.5 + + + velocities/vc-kts lt 240 + velocities/mach lt 0.55 + + + velocities/vc-kts lt 255 + velocities/mach lt 0.6 + + + + + fcs/tef-pos + 1.91 + + + + fcs/tef-norm + + + 0 + + + + 0.5 + + + + 1.0 + + + + fcs/flap-pos-norm + + + + fcs/flap-controller + fcs/speedbrake-flap + + 0 + 1 + + + + + fcs/flap-pos-out + + 0 + 1.0 + + + 0 + 30 + + fcs/flap-pos-deg + + + + + + + + + + + aero/alpha-deg ge 120 + velocities/v-fps le 10 + + + + + + + fcs/speedbrake-alpha-limiter eq 1 + fcs/speedbrake-cmd-norm eq 1 + + + + + fcs/speedbrake-initiate + + + 0 + + + + 60 + + + + fcs/speedbrake-pos-deg + + + + fcs/speedbrake-control + + 0 + 60 + + + 0 + 1 + + fcs/speedbrake-pos-norm + + + + + fcs/speedbrake-pos-norm + -1 + fcs/speedbrake-aileron + + + + fcs/speedbrake-pos-norm + -1 + fcs/speedbrake-aileron-right + + + + fcs/speedbrake-pos-norm + 1 + fcs/speedbrake-rudder + + + + fcs/speedbrake-pos-norm + 1 + fcs/speedbrake-flap + + + + + + + + gear/gear-cmd-norm + + + 0 + + + + 1 + + + + gear/gear-pos-norm + + + + fcs/steer-cmd-norm + + velocities/vg-fps + + 10.0 80 + 50.0 15 + 150.0 2 + +
+ fcs/steer-pos-deg +
+ +
+ +
+ + + + + + + velocities/mach + + 0.0000 0.0000 + 0.6000 0.01 + 0.8000 0.025 + 0.9000 0.035 + 1.0000 0.045 + 1.1000 0.1 + 1.2000 0.105 + 1.3000 0.11 + +
+
+ + + Change_in_lift_due_to_ground_effect + + aero/h_b-mac-ft + + 0.0000 1.3893 + 0.1000 1.2108 + 0.1500 1.1972 + 0.2000 1.2108 + 0.3000 1.1785 + 0.4000 1.0697 + 0.5000 1.0578 + 0.6000 1.0323 + 0.7000 1.0136 + 0.8000 1.0051 + 0.9000 1.0017 + 1.0000 1.0000 + 1.1000 1.0000 + +
+
+ + + Change_in_drag_due_to_ground_effect + + aero/h_b-mac-ft + + 0.0000 0.5235 + 0.1000 0.4759 + 0.1500 0.5250 + 0.2000 0.5388 + 0.3000 0.5924 + 0.4000 0.6471 + 0.5000 0.6941 + 0.6000 0.7624 + 0.7000 0.8253 + 0.8000 0.8812 + 0.9000 0.9412 + 1.0000 1.0000 + 1.1000 1.0000 + +
+
+ + + + + Drag_at_zero_lift + + aero/qbar-psf + metrics/Sw-sqft + aero/function/kCDge + + aero/alpha-rad + + -1.5700 1.5000 + -0.2600 0.0480 + 0.0000 0.00820 + 0.2600 0.0480 + 1.5700 1.5000 + +
+
+
+ + + Induced_drag + + aero/qbar-psf + metrics/Sw-sqft + aero/cl-squared + aero/function/kCDge + 0.0700 + + + + + Drag_due_to_mach + + aero/qbar-psf + metrics/Sw-sqft + + velocities/mach + + 0.0000 0.0000 + 0.8100 0.0000 + 1.1000 0.0220 + 1.8000 0.0140 + 2.4000 0.020 + +
+
+
+ + + Drag_due_to_flaps + + aero/qbar-psf + metrics/Sw-sqft + fcs/flap-pos-norm + aero/function/kCDge + 0.0750 + + + + + Drag_due_to_gear + + aero/qbar-psf + metrics/Sw-sqft + gear/gear-pos-norm + 0.0200 + + + + + Drag_due_to_speedbrakes + + aero/qbar-psf + metrics/Sw-sqft + fcs/speedbrake-pos-norm + 0.0240 + + + + + Drag_due_to_sideslip + + aero/qbar-psf + metrics/Sw-sqft + aero/function/kCDge + + aero/beta-rad + + -1.5700 1.2300 + -0.2600 0.0500 + 0.0000 0.0000 + 0.2600 0.0500 + 1.5700 1.2300 + +
+
+
+ + + Drag_due_to_Elevator_Deflection + + aero/qbar-psf + metrics/Sw-sqft + aero/function/kCDge + + fcs/elevator-pos-rad + + -0.5236 0.075 + -0.218 0.0375 + 0 0.010 + 0.218 0.0375 + 0.5236 0.077 + +
+
+
+ + + Drag_due_to_leading_edge_flap_deflection + + aero/qbar-psf + metrics/Sw-sqft + aero/function/kCDge + fcs/lef-pos-rad + + aero/alpha-rad + + -0.175 0.003 + -0.087 0.001 + 0.000 0.000 + 0.087 0.001 + 0.175 0.002 + 0.262 0.004 + 0.349 0.007 + 0.436 0.011 + 0.524 0.015 + 0.611 0.019 + 0.698 0.023 + 0.785 0.024 + +
+
+
+ + Drag_due_to_pitch_rate + + aero/qbar-psf + metrics/Sw-sqft + velocities/q-aero-rad_sec + aero/ci2vel + + aero/alpha-rad + + -0.175 -1.265 + -0.087 -2.139 + 0.000 -0.308 + 0.087 1.402 + 0.175 3.369 + 0.262 5.135 + 0.349 6.880 + 0.436 8.060 + 0.524 11.201 + 0.611 13.872 + 0.698 19.217 + 0.785 22.105 + +
+
+
+ + Drag_due_to_pitch_rate_and_leading_edge_flap_deflection + + aero/qbar-psf + metrics/Sw-sqft + velocities/q-aero-rad_sec + aero/ci2vel + fcs/lef-pos-rad + + aero/alpha-rad + + -0.175 0.067 + -0.087 0.034 + 0.000 0.028 + 0.087 0.029 + 0.175 0.033 + 0.262 0.059 + 0.349 0.061 + 0.436 0.027 + 0.524 0.036 + 0.611 0.047 + 0.698 0.029 + 0.785 0.015 + +
+
+
+
+ + + + Side_force_due_to_beta + + aero/qbar-psf + metrics/Sw-sqft + aero/beta-rad + -1.7875 + + + + Side_force_due_to_beta_due_to_mach + + aero/qbar-psf + metrics/Sw-sqft + aero/beta-rad + + velocities/mach + + 0.4 0.0 + 1.2 0.0573 + 1.6 -0.1719 + +
+
+
+ + Side_force_due_to_beta_rate + + aero/qbar-psf + metrics/Sw-sqft + aero/betadot-rad_sec + -0.0458 + + + + Side_force_due_to_aileron + + aero/qbar-psf + metrics/Sw-sqft + fcs/left-aileron-pos-rad + -1.1516e-3 + + + + Side_force_due_to_rudder + + aero/qbar-psf + metrics/Sw-sqft + fcs/rudder-pos-rad + 0.0619 + + + + Side_force_due_to_roll_rate + + aero/qbar-psf + metrics/Sw-sqft + aero/bi2vel + velocities/p-aero-rad_sec + 0.0399 + + + + Side_force_due_to_yaw_rate + + aero/qbar-psf + metrics/Sw-sqft + aero/bi2vel + velocities/r-aero-rad_sec + 0.1218 + + +
+ + + + + Lift_due_to_alpha + + aero/qbar-psf + metrics/Sw-sqft + + aero/alpha-rad + + -0.1745 -0.4279 + 0.7854 2.3000 + 1.3963 1.0726 + +
+
+
+ + + Lift_due_to_leading_edge_flap_deflection + + aero/qbar-psf + metrics/Sw-sqft + fcs/lef-pos-rad + aero/function/kCLge + + aero/alpha-rad + + -0.1750 -0.0120 + -0.0870 -0.0040 + 0.0000 0.0020 + 0.0870 0.0070 + 0.1750 0.0120 + 0.2620 0.0180 + 0.3490 0.0220 + 0.4360 0.0250 + 0.5240 0.0260 + 0.6110 0.0280 + 0.6980 0.0300 + 0.7850 0.032 + 0.8727 0.03 + 1.0472 0.028 + 1.2217 0.025 + 1.3963 0.02 + +
+
+
+ + Lift_due_to_alpha_rate + + aero/qbar-psf + metrics/Sw-sqft + aero/alphadot-rad_sec + aero/ci2vel + 2.5602 + + + + Lift_due_to_pitch_rate + + aero/qbar-psf + metrics/Sw-sqft + velocities/q-aero-rad_sec + aero/ci2vel + aero/function/kCLge + + aero/alpha-rad + + -0.175 8.7127 + -0.087 25.7114 + 0.000 28.9000 + 0.087 31.3973 + 0.175 31.0872 + 0.262 30.4071 + 0.349 28.9735 + 0.436 28.4242 + 0.524 27.8647 + 0.611 27.2654 + 0.698 30.5158 + 0.785 27.8165 + +
+
+
+ + Delta_Lift_due_to_flaps + + aero/qbar-psf + metrics/Sw-sqft + fcs/flap-pos-norm + aero/function/kCLge + 0.3500 + + + + Delta_Lift_due_to_speedbrake + + aero/qbar-psf + metrics/Sw-sqft + fcs/speedbrake-pos-norm + aero/function/kCLge + 0.3500 + + + + + Lift_due_to_elevator + + aero/qbar-psf + metrics/Sw-sqft + fcs/elevator-pos-rad + aero/function/kCLge + 0.190 + + +
+ + + + Roll_moment_due_to_beta + + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + aero/beta-rad + + aero/alpha-rad + + -0.0873 0.0189 + 0.0000 0.0176 + 0.0873 0.0119 + 0.1745 0.0050 + 0.2618 0.0049 + 0.3491 0.0028 + 0.4363 0.0084 + 0.5236 0.0171 + 0.6109 0.0044 + +
+
+
+ + Change_in_Roll_moment_due_to_aileron_due_to_mach + + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + aero/alpha-rad + fcs/left-aileron-pos-rad + + velocities/mach + + 0.6 0.0 + 1.2 -0.063 + 1.6 -0.063 + +
+
+
+ + Change_in_Roll_moment_due_to_rudder_due_to_mach + + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + aero/alpha-rad + fcs/rudder-pos-rad + + velocities/mach + + 0.6 0.0 + 1.6 -0.0201 + +
+
+
+ + Roll_moment_due_to_beta_rate + + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + aero/bi2vel + aero/betadot-rad_sec + -0.0037 + + + + Roll_moment_due_to_roll_rate + + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + aero/bi2vel + velocities/p-aero-rad_sec + + aero/alpha-rad + + 0.0000 -0.4000 + 0.3490 -0.3100 + +
+
+
+ + Roll_moment_due_to_yaw_rate + + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + aero/bi2vel + velocities/r-aero-rad_sec + + aero/alpha-rad + + -0.175 -0.126 + -0.087 -0.026 + 0.0 0.063 + 0.087 0.113 + 0.175 0.208 + 0.262 0.230 + 0.349 0.319 + 0.436 0.437 + 0.524 0.680 + 0.611 0.100 + 0.698 0.447 + 0.785 -0.330 + +
+
+
+ + Roll_moment_due_to_aileron + + 4 + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + fcs/left-aileron-pos-rad + + aero/alpha-rad + + -0.1745 0.045 + -0.0873 0.052 + 0.0000 0.060 + 0.0873 0.057 + 0.1745 0.052 + 0.2618 0.047 + 0.3491 0.040 + 0.4363 0.034 + 0.5236 0.029 + 0.6109 0.023 + 0.6981 0.019 + 0.7854 0.015 + 0.8727 0.012 + 1.0472 0.011 + 1.2217 0.009 + 1.3963 0.008 + +
+
+
+ + Roll_moment_due_to_rudder + + 2 + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + fcs/rudder-pos-rad + + aero/alpha-rad + + -0.1745 0.0078 + -0.0873 0.0070 + 0.0000 0.0059 + 0.0873 0.0043 + 0.1745 0.0023 + 0.2618 0.0007 + 0.3491 -0.0008 + 0.4363 0.0004 + 0.5236 0.0019 + 0.6109 0.0019 + 0.6981 0.0017 + 0.7854 0.0009 + 0.8727 0.0003 + 1.0472 0.0000 + 1.2217 0.0000 + 1.3963 0.0000 + +
+
+
+
+ + + + Pitch_moment_due_to_horizontal_tail_deflection + + + aero/qbar-psf + metrics/Sw-sqft + metrics/cbarw-ft + + aero/alpha-rad + fcs/elevator-pos-rad + + -0.5236 0.0 0.5236 + -0.0873 0.3222 -0.0546 -0.4911 + 0.0000 0.3482 0.0000 -0.4729 + 0.0873 0.3897 0.0286 -0.4782 + 0.1745 0.3689 0.0156 -0.4988 + 0.2618 0.3975 0.0546 -0.4287 + 0.3491 0.3819 0.0442 -0.2722 + 0.4363 0.4131 0.0831 -0.1637 + 0.5236 0.4599 0.1377 -0.0546 + 0.6109 0.4443 0.1325 -0.0530 + 0.6981 0.3819 0.1013 -0.0650 + 0.7854 0.3585 0.0909 -0.0624 + 0.8727 0.3430 0.1299 -0.0364 + 1.0472 0.0208 -0.1117 -0.1975 + 1.2217 0.3248 -0.4521 -0.5144 + + +
+ 1.5 +
+
+ + + Pitch_moment_due_to_thrust_vectoring + + 1.25 + aero/qbar-psf + metrics/Sw-sqft + metrics/cbarw-ft + fcs/thrust-norm + + aero/alpha-rad + fcs/tvc-pos-rad + + -0.3491 0.0 0.3491 + -0.0873 0.4417 0.0 -0.7924 + 0.0000 0.4650 0.0 -0.8366 + 0.0873 0.4448 0.0 -0.8808 + 0.1745 0.4677 0.0 -0.8886 + 0.2618 0.4755 0.0 -0.8600 + 0.3491 0.4625 0.0 -0.7534 + 0.4363 0.4963 0.0 -0.6901 + 0.5236 0.4936 0.0 -0.6408 + 0.6109 0.4781 0.0 -0.6000 + 0.6981 0.5025 0.0 -0.5454 + 0.7854 0.5015 0.0 -0.5143 + 0.8727 0.4209 0.0 -0.5284 + 1.0472 0.4417 0.0 -0.5047 + 1.2217 0.2988 0.0 -0.4883 + + +
+
+
+ + Change_in_Pitch_moment_due_to_alpha_due_to_mach + + aero/qbar-psf + metrics/Sw-sqft + metrics/cbarw-ft + aero/alpha-rad + + velocities/mach + + 0.6000 0.0000 + 0.8000 0.0974 + 0.9000 -0.3323 + 1.0000 -0.9626 + 1.1000 -0.8480 + 1.2000 -0.7907 + +
+
+
+ + Pitch_moment_due_to_pitch_rate + + aero/qbar-psf + metrics/Sw-sqft + metrics/cbarw-ft + aero/ci2vel + velocities/q-aero-rad_sec + + aero/alpha-rad + + -0.1750 -7.2100 + -0.0870 -5.4000 + 0.0000 -5.2300 + 0.0870 -5.2600 + 0.1750 -6.1100 + 0.2620 -6.6400 + 0.3490 -5.6900 + 0.4360 -6.0000 + 0.5240 -6.2000 + 0.6110 -6.4000 + 0.6980 -6.6000 + 0.7850 -6.0000 + +
+
+
+ + Pitch_moment_due_to_alpha_rate + + aero/qbar-psf + metrics/Sw-sqft + metrics/cbarw-ft + aero/ci2vel + aero/alphadot-rad_sec + + velocities/mach + + 0.2570 -1.5611 + 1.8000 -2.0500 + +
+
+
+ +
+ + + Yaw_moment_due_to_beta + + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + aero/beta-rad + + aero/alpha-rad + + -0.349 0.0745 + 0.000 0.1780 + 0.873 0.0086 + + + +
+
+
+ + Change_in_Yaw_moment_due_to_beta_due_to_mach + + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + aero/beta-rad + + velocities/mach + + 0.6000 0.0000 + 0.7000 -0.0688 + 0.8000 -0.0172 + 0.9000 0.0229 + 1.0000 0.0000 + 1.2000 -0.0688 + 1.4000 0.0057 + 1.6000 0.0688 + +
+
+
+ + Yaw_moment_due_to_beta_rate + + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + aero/bi2vel + aero/betadot-rad_sec + -0.0115 + + + + Yaw_moment_due_to_roll_rate + + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + aero/bi2vel + velocities/p-aero-rad_sec + -0.0035 + + + + Yaw_moment_due_to_yaw_rate + + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + aero/bi2vel + velocities/r-aero-rad_sec + + aero/alpha-rad + + -0.1750 -0.3800 + -0.0870 -0.3630 + 0.0000 -0.3780 + 0.0870 -0.3860 + 0.1750 -0.3700 + 0.2620 -0.4530 + 0.3490 -0.5500 + 0.4360 -0.5820 + 0.5240 -0.5950 + 0.6110 -0.6370 + 0.6980 -1.0200 + 0.7850 -0.8400 + +
+ +
+
+ + Yaw_moment_due_to_rudder + + 5 + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + fcs/rudder-pos-rad + + aero/alpha-rad + + + -0.1745 -0.048 + -0.0873 -0.045 + 0.0000 -0.045 + 0.0873 -0.045 + 0.1745 -0.044 + 0.2618 -0.045 + 0.3491 -0.047 + 0.4363 -0.048 + 0.5236 -0.040 + 0.6109 -0.033 + 0.6981 -0.025 + 0.7854 -0.017 + 0.8727 -0.010 + 1.0472 -0.0075 + 1.2217 -0.0075 + 1.3963 -0.0075 + + +
+
+
+ + Change_in_Yaw_moment_due_to_rudder_due_to_mach + + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + aero/alpha-rad + fcs/rudder-pos-rad + + velocities/mach + + 0.6000 0.0000 + 1.2000 0.0401 + 1.6000 0.0716 + +
+
+
+ + Adverse_yaw + + 2 + aero/qbar-psf + metrics/Sw-sqft + metrics/bw-ft + fcs/left-aileron-pos-rad + + aero/alpha-rad + + -0.1745 0.01 + -0.0873 0.009 + 0.0000 0.010 + 0.0873 0.009 + 0.1745 0.008 + 0.2618 0.006 + 0.3491 -0.002 + 0.4363 -0.003 + 0.5236 -0.005 + 0.6109 -0.008 + 0.6981 -0.006 + 0.7854 -0.006 + 0.8727 -0.008 + 1.0472 -0.01 + 1.2217 -0.011 + 1.3963 -0.013 + +
+
+
+
+
+ +
diff --git a/service/package-lock.json b/service/package-lock.json new file mode 100644 index 0000000..4de68c9 --- /dev/null +++ b/service/package-lock.json @@ -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" + } + } + } +} diff --git a/service/package.json b/service/package.json new file mode 100644 index 0000000..5529d1d --- /dev/null +++ b/service/package.json @@ -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" + } +} diff --git a/service/scripts/jsbsim_runner.py b/service/scripts/jsbsim_runner.py new file mode 100644 index 0000000..9097ed4 --- /dev/null +++ b/service/scripts/jsbsim_runner.py @@ -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) diff --git a/service/scripts/sweep_f22_cobra.py b/service/scripts/sweep_f22_cobra.py new file mode 100644 index 0000000..6597103 --- /dev/null +++ b/service/scripts/sweep_f22_cobra.py @@ -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() diff --git a/service/src/backends/jsbsimBackend.js b/service/src/backends/jsbsimBackend.js new file mode 100644 index 0000000..244e18b --- /dev/null +++ b/service/src/backends/jsbsimBackend.js @@ -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; +} diff --git a/service/src/backends/mockBackend.js b/service/src/backends/mockBackend.js new file mode 100644 index 0000000..10041b4 --- /dev/null +++ b/service/src/backends/mockBackend.js @@ -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; +} diff --git a/service/src/loadLocalEnv.js b/service/src/loadLocalEnv.js new file mode 100644 index 0000000..6ad30e5 --- /dev/null +++ b/service/src/loadLocalEnv.js @@ -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; + } + } +} diff --git a/service/src/server.js b/service/src/server.js new file mode 100644 index 0000000..d006193 --- /dev/null +++ b/service/src/server.js @@ -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); + }); +} diff --git a/service/src/simulator.js b/service/src/simulator.js new file mode 100644 index 0000000..b86fe56 --- /dev/null +++ b/service/src/simulator.js @@ -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); +} diff --git a/service/src/simulator.test.js b/service/src/simulator.test.js new file mode 100644 index 0000000..50d9fe8 --- /dev/null +++ b/service/src/simulator.test.js @@ -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");