Add JSBSim service backend
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ offline-deps/python-runtime/
|
|||||||
dist/
|
dist/
|
||||||
**/dist/
|
**/dist/
|
||||||
web-test/public/cesium/
|
web-test/public/cesium/
|
||||||
|
service/output/
|
||||||
coverage/
|
coverage/
|
||||||
**/coverage/
|
**/coverage/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
379
service/F22_COBRA_RESEARCH.md
Normal file
379
service/F22_COBRA_RESEARCH.md
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
# F-22 Cobra 机动研究记录
|
||||||
|
|
||||||
|
本文记录当前 JSBSim 眼镜蛇机动研究的现状、技术方案和已修改位置。范围覆盖 `service` 后端、`web-test` 前端、共享 schema,以及为 F-22 Cobra 研究复制出的本地飞机模型。
|
||||||
|
|
||||||
|
## 目前现状
|
||||||
|
|
||||||
|
项目当前以 `web-test` 为前端、`service` 为后端,通过本地 HTTP API 调用 JSBSim 完成飞行动力学仿真。
|
||||||
|
|
||||||
|
当前默认研究对象是:
|
||||||
|
|
||||||
|
- 前端 aircraftId: `f22cobra-jsbsim`
|
||||||
|
- 后端 JSBSim model id: `f22cobra`
|
||||||
|
- 模型文件: `service/aircraft/f22cobra/f22cobra.xml`
|
||||||
|
- 后端服务: `http://127.0.0.1:4317`
|
||||||
|
- 内部积分频率: `integrationRateHz = 120`
|
||||||
|
- 输出采样频率: `sampleRateHz = 30`
|
||||||
|
|
||||||
|
当前 Cobra 默认参数是一个稳定的近似 Cobra 基线,而不是追求极限 120 度迎角的表演级参数:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"aircraftId": "f22cobra-jsbsim",
|
||||||
|
"durationSec": 5,
|
||||||
|
"sampleRateHz": 30,
|
||||||
|
"integrationRateHz": 120,
|
||||||
|
"trim": false,
|
||||||
|
"initialState": {
|
||||||
|
"position": [0, 3000, 0],
|
||||||
|
"velocityMps": 120,
|
||||||
|
"headingDeg": 0
|
||||||
|
},
|
||||||
|
"maneuver": {
|
||||||
|
"type": "cobra",
|
||||||
|
"parameters": {
|
||||||
|
"controlMode": "closed-loop",
|
||||||
|
"entryDelaySec": 0.15,
|
||||||
|
"pullDurationSec": 0.7,
|
||||||
|
"holdDurationSec": 0,
|
||||||
|
"recoveryDurationSec": 2.0,
|
||||||
|
"targetAlphaDeg": 80,
|
||||||
|
"recoveryAlphaDeg": 8,
|
||||||
|
"pitchRateLimitDegS": 80,
|
||||||
|
"holdPitchRateDegS": 0,
|
||||||
|
"maxElevatorCmd": -0.45,
|
||||||
|
"recoveryElevatorCmd": 0.65,
|
||||||
|
"throttleCmd": 1,
|
||||||
|
"pullTvcCmd": -0.05,
|
||||||
|
"recoveryTvcCmd": 0.85,
|
||||||
|
"speedbrakeCmd": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在当前 F-22 模型和上述参数下,直接 JSBSim 验证的典型指标约为:
|
||||||
|
|
||||||
|
- 峰值迎角 `maxAlpha`: 约 `71.9 deg`
|
||||||
|
- 峰值俯仰角速度 `maxPitchRate`: 约 `122.9 deg/s`
|
||||||
|
- 最低速度 `minSpeed`: 约 `106.4 m/s`
|
||||||
|
- 5 秒末迎角 `endAlpha`: 约 `9.7 deg`
|
||||||
|
- 5 秒末俯仰角 `endPitch`: 约 `31.0 deg`
|
||||||
|
|
||||||
|
这个状态的意义是:先获得可重复、能恢复、不会突然翻转的基线,再继续向更高 alpha 推进。
|
||||||
|
|
||||||
|
## 技术方案
|
||||||
|
|
||||||
|
### 1. 后端用 JSBSim 做 6DoF 积分
|
||||||
|
|
||||||
|
后端 `service/scripts/jsbsim_runner.py` 是 JSBSim 的 Python runner。Node 后端通过 `service/src/backends/jsbsimBackend.js` 调用该 runner。
|
||||||
|
|
||||||
|
关键点:
|
||||||
|
|
||||||
|
- `sampleRateHz` 只控制返回给前端的采样密度。
|
||||||
|
- `integrationRateHz` 控制 JSBSim 内部积分步长,当前默认 120 Hz。
|
||||||
|
- 仿真过程中内部积分按 `integrationRateHz` 推进,输出样本按 `sampleRateHz` 抽样。
|
||||||
|
- F-22 研究默认 `trim: false`,因为 bundled F-22 在部分速度点纵向 trim 会失败,但直接 initial condition 可以运行。
|
||||||
|
|
||||||
|
### 2. 用本地 F-22 研究模型打开 TVC 控制
|
||||||
|
|
||||||
|
原始 bundled F-22 的 TVC 链路不适合直接外部命令控制。当前复制出本地模型:
|
||||||
|
|
||||||
|
```text
|
||||||
|
service/aircraft/f22cobra/f22cobra.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
并加入/改造:
|
||||||
|
|
||||||
|
- `fcs/tvc-cmd-norm`
|
||||||
|
- TVC actuator 输入改为外部命令。
|
||||||
|
- 输出仍写到 `fcs/tvc-pos-norm` / `fcs/tvc-pos-rad`,供 JSBSim 气动和遥测读取。
|
||||||
|
|
||||||
|
这样后端可以通过 `fcs/tvc-cmd-norm` 直接施加 TVC 指令。
|
||||||
|
|
||||||
|
### 3. Cobra 从开环时间线升级为闭环控制器
|
||||||
|
|
||||||
|
早期 Cobra 使用固定时间线:
|
||||||
|
|
||||||
|
```text
|
||||||
|
某时刻 elevator = X, tvc = Y, throttle = Z
|
||||||
|
```
|
||||||
|
|
||||||
|
这种开环方式容易出现:
|
||||||
|
|
||||||
|
- 抬头过猛
|
||||||
|
- 俯仰角速度尖峰
|
||||||
|
- 高迎角后恢复不下来
|
||||||
|
- 轨迹出现不自然的旋转或深失速
|
||||||
|
|
||||||
|
现在 `cobra` 默认使用:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "controlMode": "closed-loop" }
|
||||||
|
```
|
||||||
|
|
||||||
|
闭环控制器每个积分步读取当前状态:
|
||||||
|
|
||||||
|
- `aero/alpha-rad`
|
||||||
|
- `attitude/theta-rad`
|
||||||
|
- `velocities/q-rad_sec`
|
||||||
|
- `velocities/vtrue-fps`
|
||||||
|
|
||||||
|
然后计算:
|
||||||
|
|
||||||
|
- elevator command
|
||||||
|
- TVC command
|
||||||
|
- throttle command
|
||||||
|
- speedbrake command
|
||||||
|
|
||||||
|
控制目标不是“固定时间点打多少杆”,而是:
|
||||||
|
|
||||||
|
- 拉起阶段限制 pitch rate
|
||||||
|
- 接近目标 alpha 时自动减小拉起
|
||||||
|
- 恢复阶段用 elevator + TVC 把 alpha 拉回低迎角
|
||||||
|
- 避免过快掉速和深失速
|
||||||
|
|
||||||
|
旧的开环 Cobra 仍保留:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "controlMode": "open-loop" }
|
||||||
|
```
|
||||||
|
|
||||||
|
这方便后续对比开环和闭环表现。
|
||||||
|
|
||||||
|
### 4. 姿态输出改用四元数
|
||||||
|
|
||||||
|
前端之前使用 Euler/HPR 处理姿态,在接近 90 度俯仰时容易出现视觉上的突然翻转。
|
||||||
|
|
||||||
|
现在后端输出 body-to-local ENU 四元数:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"rotation": [x, y, z, w]
|
||||||
|
```
|
||||||
|
|
||||||
|
前端播放时用 quaternion slerp 插值,Cesium 渲染也直接使用四元数路径。这避免了高俯仰角附近的 Euler 奇异跳变。
|
||||||
|
|
||||||
|
### 5. 轨迹积分使用 JSBSim 地速分量
|
||||||
|
|
||||||
|
位置轨迹不再用 heading/pitch 自己推算水平位移,而是使用 JSBSim 输出的地球坐标速度:
|
||||||
|
|
||||||
|
- `velocities/v-east-fps`
|
||||||
|
- `velocities/v-north-fps`
|
||||||
|
|
||||||
|
这避免了姿态大幅变化时轨迹方向和机体方向互相污染。
|
||||||
|
|
||||||
|
### 6. 前端加入 Cobra 参数面板和遥测面板
|
||||||
|
|
||||||
|
前端现在默认选中 `f22cobra-jsbsim` 和 `Cobra`。
|
||||||
|
|
||||||
|
Cobra 参数面板可以直接调:
|
||||||
|
|
||||||
|
- `Target alpha`
|
||||||
|
- `Pitch-rate cap`
|
||||||
|
- `Pull time`
|
||||||
|
- `Recovery time`
|
||||||
|
- `Pull elevator`
|
||||||
|
- `Recovery elevator`
|
||||||
|
- `Pull TVC`
|
||||||
|
- `Recovery TVC`
|
||||||
|
|
||||||
|
右侧遥测面板显示关键曲线和 summary,便于判断问题来源:
|
||||||
|
|
||||||
|
- alpha
|
||||||
|
- pitch
|
||||||
|
- pitch rate
|
||||||
|
- velocity
|
||||||
|
- altitude delta
|
||||||
|
- elevator command
|
||||||
|
- TVC command
|
||||||
|
- speedbrake command
|
||||||
|
|
||||||
|
## 修改的地方
|
||||||
|
|
||||||
|
### service 后端
|
||||||
|
|
||||||
|
#### `service/scripts/jsbsim_runner.py`
|
||||||
|
|
||||||
|
主要改动:
|
||||||
|
|
||||||
|
- 新增 `integrationRateHz` 支持,默认 120 Hz。
|
||||||
|
- 内部积分频率与输出采样频率解耦。
|
||||||
|
- 新增/扩展 `cobra` maneuver。
|
||||||
|
- 新增 `controlMode = closed-loop` 的 Cobra 控制器。
|
||||||
|
- 保留 `controlMode = open-loop` 的时间线控制。
|
||||||
|
- 控制输入支持:
|
||||||
|
- elevator
|
||||||
|
- aileron
|
||||||
|
- rudder
|
||||||
|
- throttle
|
||||||
|
- tvc
|
||||||
|
- speedbrake
|
||||||
|
- 输出样本增加:
|
||||||
|
- `alphaDeg`
|
||||||
|
- `betaDeg`
|
||||||
|
- `qbar`
|
||||||
|
- `verticalSpeedMps`
|
||||||
|
- `pitchRateDegS`
|
||||||
|
- `rollRateDegS`
|
||||||
|
- `yawRateDegS`
|
||||||
|
- `controlInputs`
|
||||||
|
- `controlSurfaces`
|
||||||
|
- `fcs`
|
||||||
|
- 姿态输出改为 ENU body quaternion。
|
||||||
|
- 本地位置积分改用 JSBSim east/north velocity。
|
||||||
|
|
||||||
|
#### `service/aircraft/f22cobra/f22cobra.xml`
|
||||||
|
|
||||||
|
主要改动:
|
||||||
|
|
||||||
|
- 从 bundled JSBSim F-22 复制出项目本地研究模型。
|
||||||
|
- 模型名称改为 F-22 Cobra Research。
|
||||||
|
- 新增 `fcs/tvc-cmd-norm` property。
|
||||||
|
- TVC actuator 改为读取 `fcs/tvc-cmd-norm`。
|
||||||
|
- 保证外部控制能驱动 `fcs/tvc-pos-norm`。
|
||||||
|
|
||||||
|
#### `service/src/backends/jsbsimBackend.js`
|
||||||
|
|
||||||
|
主要改动:
|
||||||
|
|
||||||
|
- 新增 `f22cobra-jsbsim` aircraft。
|
||||||
|
- 将 `integrationRateHz` 透传给 Python runner。
|
||||||
|
- `cobra` / `control-script` 加入 JSBSim 后端支持列表。
|
||||||
|
- F-22 研究机型标记为 high-alpha / TVC / no-trim experimental。
|
||||||
|
|
||||||
|
#### `service/src/backends/mockBackend.js`
|
||||||
|
|
||||||
|
主要改动:
|
||||||
|
|
||||||
|
- mock backend 同步支持 `integrationRateHz`。
|
||||||
|
- mock 输出结构补齐部分 JSBSim 遥测字段。
|
||||||
|
- 支持 `control-script` / `cobra` 的视觉测试路径。
|
||||||
|
|
||||||
|
#### `service/src/simulator.test.js`
|
||||||
|
|
||||||
|
主要改动:
|
||||||
|
|
||||||
|
- 增加对 `integrationRateHz = 120` 的断言。
|
||||||
|
- 增加 `control-script` / `cobra` 基础测试。
|
||||||
|
|
||||||
|
#### `service/scripts/sweep_f22_cobra.py`
|
||||||
|
|
||||||
|
主要作用:
|
||||||
|
|
||||||
|
- 批量扫描 Cobra 参数组合。
|
||||||
|
- 计算 max alpha、max pitch、min speed、end alpha、end pitch、forward delta 等指标。
|
||||||
|
- 用 score 排序,辅助后续自动调参。
|
||||||
|
|
||||||
|
### shared schema
|
||||||
|
|
||||||
|
#### `shared/schemas/flight-sim-api.schema.json`
|
||||||
|
|
||||||
|
主要改动:
|
||||||
|
|
||||||
|
- 新增 `integrationRateHz`。
|
||||||
|
- 新增 `trim`。
|
||||||
|
- maneuver enum 增加:
|
||||||
|
- `control-script`
|
||||||
|
- `cobra`
|
||||||
|
- sample schema 增加遥测字段:
|
||||||
|
- `qbar`
|
||||||
|
- `verticalSpeedMps`
|
||||||
|
- `pitchRateDegS`
|
||||||
|
- `rollRateDegS`
|
||||||
|
- `yawRateDegS`
|
||||||
|
- `controlInputs`
|
||||||
|
- `controlSurfaces`
|
||||||
|
- `fcs`
|
||||||
|
- Cobra 参数说明中记录 `controlMode=open-loop|closed-loop`。
|
||||||
|
|
||||||
|
### web-test 前端
|
||||||
|
|
||||||
|
#### `web-test/src/api/simulationService.js`
|
||||||
|
|
||||||
|
主要改动:
|
||||||
|
|
||||||
|
- 默认 aircraft 使用 F-22 Cobra research。
|
||||||
|
- 默认 `sampleRateHz = 30`。
|
||||||
|
- 默认 `integrationRateHz = 120`。
|
||||||
|
- F-22 research 默认 `trim = false`。
|
||||||
|
- Cobra 默认参数改为稳定闭环基线。
|
||||||
|
- 支持从前端 Cobra 参数面板覆盖默认参数。
|
||||||
|
- 参数进入请求前做范围 clamp。
|
||||||
|
|
||||||
|
#### `web-test/index.html`
|
||||||
|
|
||||||
|
主要改动:
|
||||||
|
|
||||||
|
- 默认 maneuver 改为 Cobra。
|
||||||
|
- 默认 duration 改为 5 秒。
|
||||||
|
- 默认 speed 改为 120 m/s。
|
||||||
|
- 新增 Cobra Controller 参数面板。
|
||||||
|
- 新增遥测 canvas 面板。
|
||||||
|
|
||||||
|
#### `web-test/src/ui/controls.js`
|
||||||
|
|
||||||
|
主要改动:
|
||||||
|
|
||||||
|
- 读取 Cobra 参数面板输入。
|
||||||
|
- `getValues()` 返回 `cobraParameters`。
|
||||||
|
- 非 Cobra 机动时隐藏 Cobra 参数区。
|
||||||
|
|
||||||
|
#### `web-test/src/ui/researchPanel.js`
|
||||||
|
|
||||||
|
主要作用:
|
||||||
|
|
||||||
|
- 绘制 Cobra 遥测曲线。
|
||||||
|
- 输出 summary:
|
||||||
|
- max alpha
|
||||||
|
- max pitch
|
||||||
|
- max pitch rate
|
||||||
|
- min speed
|
||||||
|
- end alpha
|
||||||
|
- speedbrake 状态
|
||||||
|
|
||||||
|
#### `web-test/src/playback/trajectoryPlayer.js`
|
||||||
|
|
||||||
|
主要改动:
|
||||||
|
|
||||||
|
- 使用四元数 slerp 插值姿态。
|
||||||
|
- 减少高俯仰角播放时的姿态跳变。
|
||||||
|
|
||||||
|
#### `web-test/src/renderer/cesiumScene.js`
|
||||||
|
|
||||||
|
主要改动:
|
||||||
|
|
||||||
|
- 支持后端 quaternion rotation。
|
||||||
|
- Cesium aircraft orientation 从本地 ENU body quaternion 转换到世界坐标。
|
||||||
|
- 保留 HPR fallback。
|
||||||
|
|
||||||
|
#### `web-test/src/styles.css`
|
||||||
|
|
||||||
|
主要改动:
|
||||||
|
|
||||||
|
- 增加 Cobra 参数面板样式。
|
||||||
|
- 增加遥测面板样式。
|
||||||
|
- 调整移动端布局。
|
||||||
|
|
||||||
|
## 当前限制
|
||||||
|
|
||||||
|
1. 当前 F-22 XML 的高迎角 / 失速后气动可信度有限。
|
||||||
|
2. 目前稳定基线只能做到约 70-72 度 alpha,继续推高 alpha 容易进入深失速或恢复困难。
|
||||||
|
3. `speedbrakeCmd` 在该 F-22 模型中更像离散开关,部分小数指令不会有效展开。
|
||||||
|
4. 目前闭环控制器是工程研究用,不是完整真实 FCS。
|
||||||
|
5. 参数 sweep 还没有接入前端,只能通过脚本跑。
|
||||||
|
|
||||||
|
## 下一步建议
|
||||||
|
|
||||||
|
1. 把 `sweep_f22_cobra.py` 接到 service API,支持前端一键 sweep。
|
||||||
|
2. 增加 Cobra 参数 preset:
|
||||||
|
- stable baseline
|
||||||
|
- high-alpha attempt
|
||||||
|
- aggressive TVC recovery
|
||||||
|
- open-loop comparison
|
||||||
|
3. 为机动研究抽象统一 controller 接口,后续复用到:
|
||||||
|
- Herbst maneuver
|
||||||
|
- Kulbit
|
||||||
|
- J-turn
|
||||||
|
- tailslide
|
||||||
|
4. 进一步寻找或构建更可信的高迎角/失速后 F-22、Su-27、F-16 MATV 等 JSBSim 模型。
|
||||||
|
5. 增加自动评分指标,把“像不像 Cobra”量化,而不是只靠视觉判断。
|
||||||
98
service/README.md
Normal file
98
service/README.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Local Flight Simulation Service
|
||||||
|
|
||||||
|
Offline mock service for the first milestone.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run service
|
||||||
|
```
|
||||||
|
|
||||||
|
The default URL is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:4317
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /aircraft`
|
||||||
|
- `POST /trim`
|
||||||
|
- `POST /simulate`
|
||||||
|
- `POST /cancel`
|
||||||
|
|
||||||
|
## Coordinate and Heading Conventions
|
||||||
|
|
||||||
|
- Local position is `[east, up, north]` in meters.
|
||||||
|
- True heading `0 deg` points north.
|
||||||
|
- Clockwise heading is positive.
|
||||||
|
- Counter-clockwise heading is negative.
|
||||||
|
|
||||||
|
## Rates
|
||||||
|
|
||||||
|
- `sampleRateHz` controls the output sample frequency returned to the web client.
|
||||||
|
- `integrationRateHz` controls the internal JSBSim integration frequency and defaults to `120`.
|
||||||
|
- The web client currently requests `sampleRateHz: 30` and `integrationRateHz: 120`.
|
||||||
|
- `trim: false` skips pre-simulation trim. This is useful for F-22 maneuver research because the bundled F-22 model can run from initial conditions even when longitudinal trim fails.
|
||||||
|
|
||||||
|
The mock service currently supports:
|
||||||
|
|
||||||
|
- `straight`
|
||||||
|
- `climb`
|
||||||
|
- `descent`
|
||||||
|
- `level-turn`
|
||||||
|
- `control-script`
|
||||||
|
- `cobra`
|
||||||
|
|
||||||
|
## Maneuver Research Path
|
||||||
|
|
||||||
|
This project treats special maneuvers as reusable control-input studies:
|
||||||
|
|
||||||
|
1. Use `control-script` for raw elevator/aileron/rudder/throttle timelines.
|
||||||
|
2. Build named maneuver templates, such as `cobra`, on top of those timelines.
|
||||||
|
3. Compare mock output against the JSBSim backend.
|
||||||
|
4. Improve or replace aircraft XML models when high-alpha/post-stall data is not credible.
|
||||||
|
|
||||||
|
`control-script` parameters:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timeline": [
|
||||||
|
{ "t": 0.0, "elevator": 0, "aileron": 0, "rudder": 0, "throttle": 0.85 },
|
||||||
|
{ "t": 0.5, "elevator": 0.8, "aileron": 0, "rudder": 0, "throttle": 1.0 },
|
||||||
|
{ "t": 1.6, "elevator": 0, "aileron": 0, "rudder": 0, "throttle": 1.0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`cobra` is currently an experimental template. It is useful for plumbing and visualization, but physical credibility depends on the selected JSBSim aircraft model having high-alpha and post-stall aerodynamic tables.
|
||||||
|
|
||||||
|
The default F-22 research path uses `f22cobra-jsbsim`, a project-local copy of the bundled F-22 model with manual TVC command support. The current stable near-cobra baseline uses the closed-loop Cobra controller:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"aircraftId": "f22cobra-jsbsim",
|
||||||
|
"durationSec": 5,
|
||||||
|
"initialState": { "position": [0, 3000, 0], "velocityMps": 120, "headingDeg": 0 },
|
||||||
|
"trim": false,
|
||||||
|
"maneuver": {
|
||||||
|
"type": "cobra",
|
||||||
|
"parameters": {
|
||||||
|
"controlMode": "closed-loop",
|
||||||
|
"pullDurationSec": 0.7,
|
||||||
|
"recoveryDurationSec": 2.0,
|
||||||
|
"targetAlphaDeg": 80,
|
||||||
|
"pitchRateLimitDegS": 80,
|
||||||
|
"maxElevatorCmd": -0.45,
|
||||||
|
"recoveryElevatorCmd": 0.65,
|
||||||
|
"pullTvcCmd": -0.05,
|
||||||
|
"recoveryTvcCmd": 0.85,
|
||||||
|
"throttleCmd": 1,
|
||||||
|
"speedbrakeCmd": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This baseline reaches roughly 70-72 degrees alpha in the current model, keeps peak pitch rate around 120-125 deg/s, and recovers to low alpha by the end of the run. Higher-alpha open-loop settings can still be tested with `"controlMode": "open-loop"`, but they tend to deep-stall or over-rotate with the current F-22 aerodynamic tables.
|
||||||
2766
service/aircraft/f22cobra/f22cobra.xml
Normal file
2766
service/aircraft/f22cobra/f22cobra.xml
Normal file
File diff suppressed because it is too large
Load Diff
15
service/package-lock.json
generated
Normal file
15
service/package-lock.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "local-flight-sim-service",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "local-flight-sim-service",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
service/package.json
Normal file
14
service/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "local-flight-sim-service",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"test": "node src/simulator.test.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
587
service/scripts/jsbsim_runner.py
Normal file
587
service/scripts/jsbsim_runner.py
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
FT_PER_M = 3.280839895
|
||||||
|
M_PER_FT = 1 / FT_PER_M
|
||||||
|
KTS_PER_MPS = 1.943844492
|
||||||
|
MPS_PER_FPS = 0.3048
|
||||||
|
DEFAULT_INTEGRATION_RATE_HZ = 120
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
request = json.load(sys.stdin)
|
||||||
|
operation = request.get("operation")
|
||||||
|
|
||||||
|
if operation == "trim":
|
||||||
|
response = trim_initial_state(request)
|
||||||
|
elif operation == "simulate":
|
||||||
|
response = simulate(request)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Unsupported JSBSim runner operation: {operation}")
|
||||||
|
|
||||||
|
json.dump(response, sys.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def create_fdm(aircraft_id, integration_rate_hz):
|
||||||
|
try:
|
||||||
|
import jsbsim
|
||||||
|
except ImportError as error:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Python package 'jsbsim' is not installed. Install it with: pip install jsbsim"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
root_dir = os.environ.get("JSBSIM_ROOT")
|
||||||
|
fdm = jsbsim.FGFDMExec(root_dir) if root_dir else jsbsim.FGFDMExec(None)
|
||||||
|
fdm.set_debug_level(0)
|
||||||
|
fdm.disable_output()
|
||||||
|
fdm.set_dt(1.0 / integration_rate_hz)
|
||||||
|
|
||||||
|
if aircraft_id == "f22cobra":
|
||||||
|
package_root = os.path.dirname(jsbsim.__file__)
|
||||||
|
service_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
fdm.set_aircraft_path(os.path.join(service_root, "aircraft"))
|
||||||
|
fdm.set_engine_path(os.path.join(package_root, "engine"))
|
||||||
|
fdm.set_systems_path(os.path.join(package_root, "systems"))
|
||||||
|
elif root_dir:
|
||||||
|
fdm.set_aircraft_path(os.path.join(root_dir, "aircraft"))
|
||||||
|
fdm.set_engine_path(os.path.join(root_dir, "engine"))
|
||||||
|
fdm.set_systems_path(os.path.join(root_dir, "systems"))
|
||||||
|
|
||||||
|
if not fdm.load_model(aircraft_id):
|
||||||
|
raise RuntimeError(f"JSBSim could not load aircraft model: {aircraft_id}")
|
||||||
|
|
||||||
|
return fdm
|
||||||
|
|
||||||
|
|
||||||
|
def trim_initial_state(request):
|
||||||
|
integration_rate_hz = integration_rate_from_request(request)
|
||||||
|
aircraft_id = normalize_aircraft_id(request.get("aircraftId"))
|
||||||
|
trim_request = request.get("trim", request)
|
||||||
|
fdm = create_fdm(aircraft_id, integration_rate_hz)
|
||||||
|
|
||||||
|
altitude_m = number_or_default(trim_request.get("altitudeM"), 1000)
|
||||||
|
speed_mps = number_or_default(trim_request.get("speedMps"), 70)
|
||||||
|
heading_deg = number_or_default(trim_request.get("headingDeg"), 0)
|
||||||
|
gamma_deg = number_or_default(trim_request.get("flightPathAngleDeg"), 0)
|
||||||
|
|
||||||
|
configure_initial_conditions(
|
||||||
|
fdm,
|
||||||
|
altitude_m=altitude_m,
|
||||||
|
speed_mps=speed_mps,
|
||||||
|
heading_deg=heading_deg,
|
||||||
|
gamma_deg=gamma_deg,
|
||||||
|
)
|
||||||
|
run_trim(fdm)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"aircraftId": request.get("aircraftId"),
|
||||||
|
"state": read_state(fdm, fallback_position=[0, altitude_m, 0]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def simulate(request):
|
||||||
|
sample_rate_hz = max(1, min(240, number_or_default(request.get("sampleRateHz"), 30)))
|
||||||
|
integration_rate_hz = integration_rate_from_request(request)
|
||||||
|
integration_dt = 1.0 / integration_rate_hz
|
||||||
|
duration_sec = max(0.1, min(3600, number_or_default(request.get("durationSec"), 10)))
|
||||||
|
aircraft_id = normalize_aircraft_id(request.get("aircraftId"))
|
||||||
|
maneuver = request.get("maneuver") or {"type": "straight", "parameters": {}}
|
||||||
|
parameters = maneuver.get("parameters") or {}
|
||||||
|
initial_state = request.get("initialState") or {}
|
||||||
|
|
||||||
|
speed_mps = number_or_default(initial_state.get("velocityMps"), 70)
|
||||||
|
heading_deg = number_or_default(initial_state.get("headingDeg"), 0)
|
||||||
|
start_position = vector_or_default(initial_state.get("position"), [0, 1000, 0])
|
||||||
|
gamma_deg = maneuver_gamma_deg(maneuver.get("type"), parameters)
|
||||||
|
|
||||||
|
fdm = create_fdm(aircraft_id, integration_rate_hz)
|
||||||
|
configure_initial_conditions(
|
||||||
|
fdm,
|
||||||
|
altitude_m=start_position[1],
|
||||||
|
speed_mps=speed_mps,
|
||||||
|
heading_deg=heading_deg,
|
||||||
|
gamma_deg=gamma_deg,
|
||||||
|
)
|
||||||
|
if should_trim(request, parameters):
|
||||||
|
run_trim(fdm)
|
||||||
|
|
||||||
|
total_samples = int(math.floor(duration_sec * sample_rate_hz)) + 1
|
||||||
|
samples = []
|
||||||
|
east = start_position[0]
|
||||||
|
north = start_position[2]
|
||||||
|
sim_time = 0.0
|
||||||
|
|
||||||
|
for index in range(total_samples):
|
||||||
|
target_t = min(index / sample_rate_hz, duration_sec)
|
||||||
|
|
||||||
|
while sim_time < target_t - 1e-9:
|
||||||
|
step_dt = min(integration_dt, target_t - sim_time)
|
||||||
|
fdm.set_dt(step_dt)
|
||||||
|
controls = controls_at_time(maneuver, sim_time, fdm)
|
||||||
|
apply_controls(fdm, controls)
|
||||||
|
fdm.run()
|
||||||
|
state = read_state(fdm, fallback_position=[east, start_position[1], north])
|
||||||
|
east += get_prop(fdm, "velocities/v-east-fps", 0) * MPS_PER_FPS * step_dt
|
||||||
|
north += get_prop(fdm, "velocities/v-north-fps", 0) * MPS_PER_FPS * step_dt
|
||||||
|
sim_time += step_dt
|
||||||
|
|
||||||
|
state = read_state(fdm, fallback_position=start_position if index == 0 else [east, start_position[1], north])
|
||||||
|
|
||||||
|
altitude_m = get_prop(fdm, "position/h-sl-ft", start_position[1] * FT_PER_M) * M_PER_FT
|
||||||
|
state["position"] = [round_float(east), round_float(altitude_m), round_float(north)]
|
||||||
|
state["t"] = round_float(target_t)
|
||||||
|
samples.append(state)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": f"sim_{uuid.uuid4()}",
|
||||||
|
"aircraftId": request.get("aircraftId"),
|
||||||
|
"sampleRateHz": sample_rate_hz,
|
||||||
|
"integrationRateHz": integration_rate_hz,
|
||||||
|
"durationSec": duration_sec,
|
||||||
|
"coordinateFrame": "local-eun",
|
||||||
|
"headingConvention": "true heading: 0 deg north, clockwise positive, counter-clockwise negative",
|
||||||
|
"samples": samples,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def configure_initial_conditions(fdm, altitude_m, speed_mps, heading_deg, gamma_deg):
|
||||||
|
set_prop(fdm, "ic/h-sl-ft", altitude_m * FT_PER_M)
|
||||||
|
set_prop(fdm, "ic/vc-kts", speed_mps * KTS_PER_MPS)
|
||||||
|
set_prop(fdm, "ic/gamma-deg", gamma_deg)
|
||||||
|
set_prop(fdm, "ic/psi-true-deg", heading_deg)
|
||||||
|
set_prop(fdm, "ic/lat-geod-deg", 0)
|
||||||
|
set_prop(fdm, "ic/long-gc-deg", 0)
|
||||||
|
set_prop(fdm, "ic/terrain-elevation-ft", 0)
|
||||||
|
set_prop(fdm, "propulsion/set-running", -1)
|
||||||
|
if not fdm.run_ic():
|
||||||
|
raise RuntimeError("JSBSim run_ic() failed.")
|
||||||
|
set_airborne_configuration(fdm)
|
||||||
|
|
||||||
|
|
||||||
|
def set_airborne_configuration(fdm):
|
||||||
|
set_prop(fdm, "gear/gear-cmd-norm", 0)
|
||||||
|
set_prop(fdm, "gear/gear-pos-norm", 0)
|
||||||
|
set_prop(fdm, "fcs/flap-cmd-norm", 0)
|
||||||
|
set_prop(fdm, "fcs/flap-pos-norm", 0)
|
||||||
|
set_prop(fdm, "fcs/speedbrake-cmd-norm", 0)
|
||||||
|
set_prop(fdm, "fcs/speedbrake-pos-norm", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def run_trim(fdm):
|
||||||
|
try:
|
||||||
|
fdm.do_trim(0)
|
||||||
|
except Exception as error:
|
||||||
|
raise RuntimeError(f"JSBSim longitudinal trim failed: {error}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def read_state(fdm, fallback_position):
|
||||||
|
phi = get_prop(fdm, "attitude/phi-rad", 0)
|
||||||
|
theta = get_prop(fdm, "attitude/theta-rad", 0)
|
||||||
|
psi = get_prop(fdm, "attitude/psi-rad", 0)
|
||||||
|
heading_deg = normalize_heading(math.degrees(psi))
|
||||||
|
altitude_m = get_prop(fdm, "position/h-sl-ft", fallback_position[1] * FT_PER_M) * M_PER_FT
|
||||||
|
velocity_mps = get_prop(fdm, "velocities/vtrue-fps", 0) * MPS_PER_FPS
|
||||||
|
vertical_speed_mps = get_prop(fdm, "velocities/h-dot-fps", 0) * MPS_PER_FPS
|
||||||
|
pitch_rate_deg_s = math.degrees(get_prop(fdm, "velocities/q-rad_sec", 0))
|
||||||
|
roll_rate_deg_s = math.degrees(get_prop(fdm, "velocities/p-rad_sec", 0))
|
||||||
|
yaw_rate_deg_s = math.degrees(get_prop(fdm, "velocities/r-rad_sec", 0))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"position": [fallback_position[0], round_float(altitude_m), fallback_position[2]],
|
||||||
|
"rotation": euler_to_enu_body_quat(phi, theta, psi),
|
||||||
|
"velocityMps": round_float(velocity_mps),
|
||||||
|
"headingDeg": round_float(heading_deg),
|
||||||
|
"pitchDeg": round_float(math.degrees(theta)),
|
||||||
|
"rollDeg": round_float(math.degrees(phi)),
|
||||||
|
"alphaDeg": round_float(math.degrees(get_prop(fdm, "aero/alpha-rad", 0))),
|
||||||
|
"betaDeg": round_float(math.degrees(get_prop(fdm, "aero/beta-rad", 0))),
|
||||||
|
"loadFactor": round_float(get_prop(fdm, "accelerations/n-pilot-z-norm", 1)),
|
||||||
|
"mach": round_float(get_prop(fdm, "velocities/mach", 0)),
|
||||||
|
"qbar": round_float(get_prop(fdm, "aero/qbar-psf", 0)),
|
||||||
|
"verticalSpeedMps": round_float(vertical_speed_mps),
|
||||||
|
"pitchRateDegS": round_float(pitch_rate_deg_s),
|
||||||
|
"rollRateDegS": round_float(roll_rate_deg_s),
|
||||||
|
"yawRateDegS": round_float(yaw_rate_deg_s),
|
||||||
|
"throttle": round_float(get_prop(fdm, "fcs/throttle-cmd-norm", 0)),
|
||||||
|
"controlInputs": {
|
||||||
|
"elevator": round_float(get_prop(fdm, "fcs/elevator-cmd-norm", 0)),
|
||||||
|
"aileron": round_float(get_prop(fdm, "fcs/aileron-cmd-norm", 0)),
|
||||||
|
"rudder": round_float(get_prop(fdm, "fcs/rudder-cmd-norm", 0)),
|
||||||
|
"throttle": round_float(get_prop(fdm, "fcs/throttle-cmd-norm", 0)),
|
||||||
|
"tvc": round_float(get_prop(fdm, "fcs/tvc-cmd-norm", 0)),
|
||||||
|
"speedbrake": round_float(get_prop(fdm, "fcs/speedbrake-cmd-norm", 0)),
|
||||||
|
},
|
||||||
|
"controlSurfaces": {
|
||||||
|
"elevatorDeg": round_float(math.degrees(get_prop(fdm, "fcs/elevator-pos-rad", 0))),
|
||||||
|
"aileronDeg": round_float(math.degrees(get_prop(fdm, "fcs/aileron-pos-rad", 0))),
|
||||||
|
"rudderDeg": round_float(math.degrees(get_prop(fdm, "fcs/rudder-pos-rad", 0))),
|
||||||
|
"tvcDeg": round_float(math.degrees(get_prop(fdm, "fcs/tvc-pos-rad", 0))),
|
||||||
|
"speedbrakeNorm": round_float(get_prop(fdm, "fcs/speedbrake-pos-norm", 0)),
|
||||||
|
},
|
||||||
|
"fcs": {
|
||||||
|
"tvcPosNorm": round_float(get_prop(fdm, "fcs/tvc-pos-norm", 0)),
|
||||||
|
"tvcInhibit": round_float(get_prop(fdm, "fcs/tvc-inhibit", 0)),
|
||||||
|
"thrustNorm": round_float(get_prop(fdm, "fcs/thrust-norm", 0)),
|
||||||
|
"throttleOverride": round_float(get_prop(fdm, "fcs/throttle-override", 0)),
|
||||||
|
"pitchRateCmd": round_float(get_prop(fdm, "fcs/pitch-rate-cmd", 0)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def maneuver_gamma_deg(maneuver_type, parameters):
|
||||||
|
if maneuver_type == "climb":
|
||||||
|
return abs(number_or_default(parameters.get("flightPathAngleDeg"), 8))
|
||||||
|
if maneuver_type == "descent":
|
||||||
|
return -abs(number_or_default(parameters.get("flightPathAngleDeg"), 8))
|
||||||
|
if maneuver_type == "straight":
|
||||||
|
return number_or_default(parameters.get("flightPathAngleDeg"), 0)
|
||||||
|
if maneuver_type in ("control-script", "cobra"):
|
||||||
|
return number_or_default(parameters.get("flightPathAngleDeg"), 0)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"JSBSim minimal runner supports straight/climb/descent/control-script/cobra only. Unsupported maneuver.type: {maneuver_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def controls_at_time(maneuver, t, fdm=None):
|
||||||
|
maneuver_type = maneuver.get("type")
|
||||||
|
parameters = maneuver.get("parameters") or {}
|
||||||
|
|
||||||
|
if maneuver_type == "cobra":
|
||||||
|
if parameters.get("controlMode", "closed-loop") == "closed-loop" and fdm is not None:
|
||||||
|
return cobra_closed_loop_controls(parameters, t, fdm)
|
||||||
|
timeline = cobra_timeline(parameters)
|
||||||
|
elif maneuver_type == "control-script":
|
||||||
|
timeline = parameters.get("timeline")
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"elevator": 0,
|
||||||
|
"aileron": 0,
|
||||||
|
"rudder": 0,
|
||||||
|
"throttle": number_or_default(parameters.get("throttle"), 0.82),
|
||||||
|
"tvc": 0,
|
||||||
|
"speedbrake": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not isinstance(timeline, list) or len(timeline) == 0:
|
||||||
|
return {"elevator": 0, "aileron": 0, "rudder": 0, "throttle": 0.82, "tvc": 0, "speedbrake": 0}
|
||||||
|
|
||||||
|
points = sorted(
|
||||||
|
[point for point in timeline if isinstance(point, dict)],
|
||||||
|
key=lambda point: number_or_default(point.get("t"), 0),
|
||||||
|
)
|
||||||
|
if not points:
|
||||||
|
return {"elevator": 0, "aileron": 0, "rudder": 0, "throttle": 0.82}
|
||||||
|
|
||||||
|
if t <= number_or_default(points[0].get("t"), 0):
|
||||||
|
return control_point(points[0])
|
||||||
|
|
||||||
|
for index in range(1, len(points)):
|
||||||
|
previous = points[index - 1]
|
||||||
|
current = points[index]
|
||||||
|
previous_t = number_or_default(previous.get("t"), 0)
|
||||||
|
current_t = number_or_default(current.get("t"), previous_t)
|
||||||
|
if t <= current_t:
|
||||||
|
amount = 0 if current_t <= previous_t else (t - previous_t) / (current_t - previous_t)
|
||||||
|
return interpolate_controls(control_point(previous), control_point(current), amount)
|
||||||
|
|
||||||
|
return control_point(points[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def cobra_closed_loop_controls(parameters, t, fdm):
|
||||||
|
entry_delay = max(0, number_or_default(parameters.get("entryDelaySec"), 0.15))
|
||||||
|
pull_duration = max(0.1, number_or_default(parameters.get("pullDurationSec"), 0.7))
|
||||||
|
hold_duration = max(0, number_or_default(parameters.get("holdDurationSec"), 0))
|
||||||
|
recovery_duration = max(0.1, number_or_default(parameters.get("recoveryDurationSec"), 2.0))
|
||||||
|
target_alpha = clamp(number_or_default(parameters.get("targetAlphaDeg"), 80), 45, 130)
|
||||||
|
recovery_alpha = clamp(number_or_default(parameters.get("recoveryAlphaDeg"), 8), 0, 35)
|
||||||
|
pitch_rate_limit = max(20, number_or_default(parameters.get("pitchRateLimitDegS"), 80))
|
||||||
|
hold_pitch_rate = number_or_default(parameters.get("holdPitchRateDegS"), 0)
|
||||||
|
max_pull_elevator = clamp(number_or_default(parameters.get("maxElevatorCmd"), -0.45), -1, 0)
|
||||||
|
max_recovery_elevator = clamp(number_or_default(parameters.get("recoveryElevatorCmd"), 0.65), 0, 1)
|
||||||
|
pull_tvc = clamp(number_or_default(parameters.get("pullTvcCmd"), -0.05), -1, 0)
|
||||||
|
recovery_tvc = clamp(number_or_default(parameters.get("recoveryTvcCmd"), 0.85), -1, 1)
|
||||||
|
throttle = clamp(number_or_default(parameters.get("throttleCmd"), 1), 0, 1)
|
||||||
|
speedbrake = clamp(number_or_default(parameters.get("speedbrakeCmd"), 0), 0, 1)
|
||||||
|
|
||||||
|
alpha = math.degrees(get_prop(fdm, "aero/alpha-rad", 0))
|
||||||
|
pitch = math.degrees(get_prop(fdm, "attitude/theta-rad", 0))
|
||||||
|
pitch_rate = math.degrees(get_prop(fdm, "velocities/q-rad_sec", 0))
|
||||||
|
speed_mps = get_prop(fdm, "velocities/vtrue-fps", 0) * MPS_PER_FPS
|
||||||
|
|
||||||
|
pull_end = entry_delay + pull_duration
|
||||||
|
hold_end = pull_end + hold_duration
|
||||||
|
recovery_end = hold_end + recovery_duration
|
||||||
|
|
||||||
|
if t < entry_delay:
|
||||||
|
return {
|
||||||
|
"elevator": 0,
|
||||||
|
"aileron": 0,
|
||||||
|
"rudder": 0,
|
||||||
|
"throttle": throttle,
|
||||||
|
"tvc": 0,
|
||||||
|
"speedbrake": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if t < pull_end:
|
||||||
|
alpha_margin = target_alpha - alpha
|
||||||
|
alpha_scale = clamp(alpha_margin / 24, 0, 1)
|
||||||
|
rate_excess = max(0, pitch_rate - pitch_rate_limit)
|
||||||
|
rate_relief = clamp(rate_excess / pitch_rate_limit, 0, 1)
|
||||||
|
speed_relief = clamp((115 - speed_mps) / 45, 0, 0.35)
|
||||||
|
elevator = max_pull_elevator * alpha_scale + max_recovery_elevator * (rate_relief + speed_relief)
|
||||||
|
tvc = pull_tvc * alpha_scale
|
||||||
|
brake = speedbrake if alpha > 18 else 0
|
||||||
|
elif t < hold_end:
|
||||||
|
elevator = cobra_track_alpha(
|
||||||
|
target_alpha,
|
||||||
|
hold_pitch_rate,
|
||||||
|
alpha,
|
||||||
|
pitch_rate,
|
||||||
|
max_pull_elevator,
|
||||||
|
max_recovery_elevator,
|
||||||
|
)
|
||||||
|
tvc = pull_tvc * clamp((target_alpha - alpha) / 20, -0.4, 1)
|
||||||
|
brake = speedbrake
|
||||||
|
else:
|
||||||
|
recovery_amount = clamp((t - hold_end) / recovery_duration, 0, 1)
|
||||||
|
desired_alpha = target_alpha + (recovery_alpha - target_alpha) * smoothstep(recovery_amount)
|
||||||
|
desired_pitch_rate = 18 + (0 - 18) * recovery_amount
|
||||||
|
elevator = cobra_track_alpha(
|
||||||
|
desired_alpha,
|
||||||
|
desired_pitch_rate,
|
||||||
|
alpha,
|
||||||
|
pitch_rate,
|
||||||
|
max_pull_elevator * 0.55,
|
||||||
|
max_recovery_elevator,
|
||||||
|
)
|
||||||
|
if pitch > 70 and pitch_rate > 25:
|
||||||
|
elevator = max(elevator, max_recovery_elevator * 0.75)
|
||||||
|
tvc = recovery_tvc * clamp((alpha - desired_alpha) / 35, 0, 1)
|
||||||
|
brake = speedbrake if t < recovery_end and alpha > 35 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"elevator": clamp(elevator, -1, 1),
|
||||||
|
"aileron": 0,
|
||||||
|
"rudder": 0,
|
||||||
|
"throttle": throttle,
|
||||||
|
"tvc": clamp(tvc, -1, 1),
|
||||||
|
"speedbrake": brake,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cobra_track_alpha(target_alpha, target_pitch_rate, alpha, pitch_rate, pull_limit, recovery_limit):
|
||||||
|
alpha_error = target_alpha - alpha
|
||||||
|
rate_error = pitch_rate - target_pitch_rate
|
||||||
|
elevator = -0.018 * alpha_error + 0.006 * rate_error
|
||||||
|
return clamp(elevator, pull_limit, recovery_limit)
|
||||||
|
|
||||||
|
|
||||||
|
def cobra_timeline(parameters):
|
||||||
|
pull_duration = max(0.1, number_or_default(parameters.get("pullDurationSec"), 0.9))
|
||||||
|
unload_duration = max(0.1, number_or_default(parameters.get("unloadDurationSec"), 0.7))
|
||||||
|
recovery_duration = max(0.1, number_or_default(parameters.get("recoveryDurationSec"), 2.0))
|
||||||
|
max_elevator = clamp(number_or_default(parameters.get("maxElevatorCmd"), -1.0), -1, 1)
|
||||||
|
recovery_elevator = clamp(number_or_default(parameters.get("recoveryElevatorCmd"), 0.35), -1, 1)
|
||||||
|
throttle = clamp(number_or_default(parameters.get("throttleCmd"), 1.0), 0, 1)
|
||||||
|
pull_tvc = clamp(number_or_default(parameters.get("pullTvcCmd"), 0), -1, 1)
|
||||||
|
recovery_tvc = clamp(number_or_default(parameters.get("recoveryTvcCmd"), 1), -1, 1)
|
||||||
|
speedbrake = clamp(number_or_default(parameters.get("speedbrakeCmd"), 0), 0, 1)
|
||||||
|
t0 = 0
|
||||||
|
t1 = t0 + 0.2
|
||||||
|
t2 = t1 + pull_duration
|
||||||
|
t3 = t2 + unload_duration
|
||||||
|
t4 = t3 + recovery_duration
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"t": t0, "elevator": 0, "aileron": 0, "rudder": 0, "throttle": throttle, "tvc": 0, "speedbrake": 0},
|
||||||
|
{"t": t1, "elevator": max_elevator, "aileron": 0, "rudder": 0, "throttle": throttle, "tvc": pull_tvc, "speedbrake": speedbrake},
|
||||||
|
{"t": t2, "elevator": 0, "aileron": 0, "rudder": 0, "throttle": throttle, "tvc": pull_tvc, "speedbrake": speedbrake},
|
||||||
|
{"t": t3, "elevator": recovery_elevator, "aileron": 0, "rudder": 0, "throttle": throttle, "tvc": recovery_tvc, "speedbrake": speedbrake},
|
||||||
|
{"t": t4, "elevator": 0, "aileron": 0, "rudder": 0, "throttle": throttle, "tvc": 0, "speedbrake": 0},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_controls(fdm, controls):
|
||||||
|
set_prop(fdm, "fcs/elevator-cmd-norm", clamp(controls.get("elevator"), -1, 1))
|
||||||
|
set_prop(fdm, "fcs/aileron-cmd-norm", clamp(controls.get("aileron"), -1, 1))
|
||||||
|
set_prop(fdm, "fcs/rudder-cmd-norm", clamp(controls.get("rudder"), -1, 1))
|
||||||
|
set_prop(fdm, "fcs/throttle-cmd-norm", clamp(controls.get("throttle"), 0, 1))
|
||||||
|
set_prop(fdm, "fcs/tvc-cmd-norm", clamp(controls.get("tvc"), -1, 1))
|
||||||
|
set_prop(fdm, "fcs/speedbrake-cmd-norm", clamp(controls.get("speedbrake"), 0, 1))
|
||||||
|
|
||||||
|
|
||||||
|
def control_point(point):
|
||||||
|
return {
|
||||||
|
"elevator": clamp(number_or_default(point.get("elevator"), 0), -1, 1),
|
||||||
|
"aileron": clamp(number_or_default(point.get("aileron"), 0), -1, 1),
|
||||||
|
"rudder": clamp(number_or_default(point.get("rudder"), 0), -1, 1),
|
||||||
|
"throttle": clamp(number_or_default(point.get("throttle"), 0.82), 0, 1),
|
||||||
|
"tvc": clamp(number_or_default(point.get("tvc"), 0), -1, 1),
|
||||||
|
"speedbrake": clamp(number_or_default(point.get("speedbrake"), 0), 0, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def interpolate_controls(start, end, amount):
|
||||||
|
return {
|
||||||
|
"elevator": start["elevator"] + (end["elevator"] - start["elevator"]) * amount,
|
||||||
|
"aileron": start["aileron"] + (end["aileron"] - start["aileron"]) * amount,
|
||||||
|
"rudder": start["rudder"] + (end["rudder"] - start["rudder"]) * amount,
|
||||||
|
"throttle": start["throttle"] + (end["throttle"] - start["throttle"]) * amount,
|
||||||
|
"tvc": start["tvc"] + (end["tvc"] - start["tvc"]) * amount,
|
||||||
|
"speedbrake": start["speedbrake"] + (end["speedbrake"] - start["speedbrake"]) * amount,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_aircraft_id(aircraft_id):
|
||||||
|
if not aircraft_id:
|
||||||
|
return "c172p"
|
||||||
|
return aircraft_id.replace("-jsbsim", "")
|
||||||
|
|
||||||
|
|
||||||
|
def get_prop(fdm, name, fallback):
|
||||||
|
try:
|
||||||
|
return float(fdm.get_property_value(name))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
return float(fdm[name])
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def set_prop(fdm, name, value):
|
||||||
|
try:
|
||||||
|
fdm.set_property_value(name, value)
|
||||||
|
except Exception:
|
||||||
|
fdm[name] = value
|
||||||
|
|
||||||
|
|
||||||
|
def vector_or_default(value, fallback):
|
||||||
|
if not isinstance(value, list) or len(value) != 3:
|
||||||
|
return fallback
|
||||||
|
return [number_or_default(value[index], fallback[index]) for index in range(3)]
|
||||||
|
|
||||||
|
|
||||||
|
def number_or_default(value, fallback):
|
||||||
|
return value if isinstance(value, (int, float)) and math.isfinite(value) else fallback
|
||||||
|
|
||||||
|
|
||||||
|
def should_trim(request, parameters):
|
||||||
|
trim_value = request.get("trim", parameters.get("trim", True))
|
||||||
|
return trim_value is not False
|
||||||
|
|
||||||
|
|
||||||
|
def integration_rate_from_request(request):
|
||||||
|
return int(
|
||||||
|
max(
|
||||||
|
1,
|
||||||
|
min(
|
||||||
|
1000,
|
||||||
|
number_or_default(
|
||||||
|
request.get("integrationRateHz"),
|
||||||
|
DEFAULT_INTEGRATION_RATE_HZ,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clamp(value, minimum, maximum):
|
||||||
|
return max(minimum, min(maximum, number_or_default(value, minimum)))
|
||||||
|
|
||||||
|
|
||||||
|
def smoothstep(value):
|
||||||
|
amount = clamp(value, 0, 1)
|
||||||
|
return amount * amount * (3 - 2 * amount)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_heading(value):
|
||||||
|
heading = value % 360
|
||||||
|
if heading > 180:
|
||||||
|
heading -= 360
|
||||||
|
if heading <= -180:
|
||||||
|
heading += 360
|
||||||
|
return heading
|
||||||
|
|
||||||
|
|
||||||
|
def euler_to_enu_body_quat(phi, theta, psi):
|
||||||
|
body_to_ned = body_to_ned_matrix(phi, theta, psi)
|
||||||
|
body_to_enu = [
|
||||||
|
[body_to_ned[1][0], body_to_ned[1][1], body_to_ned[1][2]],
|
||||||
|
[body_to_ned[0][0], body_to_ned[0][1], body_to_ned[0][2]],
|
||||||
|
[-body_to_ned[2][0], -body_to_ned[2][1], -body_to_ned[2][2]],
|
||||||
|
]
|
||||||
|
return matrix_to_quat(body_to_enu)
|
||||||
|
|
||||||
|
|
||||||
|
def body_to_ned_matrix(phi, theta, psi):
|
||||||
|
cphi = math.cos(phi)
|
||||||
|
sphi = math.sin(phi)
|
||||||
|
ctheta = math.cos(theta)
|
||||||
|
stheta = math.sin(theta)
|
||||||
|
cpsi = math.cos(psi)
|
||||||
|
spsi = math.sin(psi)
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
ctheta * cpsi,
|
||||||
|
sphi * stheta * cpsi - cphi * spsi,
|
||||||
|
cphi * stheta * cpsi + sphi * spsi,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
ctheta * spsi,
|
||||||
|
sphi * stheta * spsi + cphi * cpsi,
|
||||||
|
cphi * stheta * spsi - sphi * cpsi,
|
||||||
|
],
|
||||||
|
[-stheta, sphi * ctheta, cphi * ctheta],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def matrix_to_quat(matrix):
|
||||||
|
m00, m01, m02 = matrix[0]
|
||||||
|
m10, m11, m12 = matrix[1]
|
||||||
|
m20, m21, m22 = matrix[2]
|
||||||
|
trace = m00 + m11 + m22
|
||||||
|
|
||||||
|
if trace > 0:
|
||||||
|
scale = math.sqrt(trace + 1.0) * 2
|
||||||
|
w = 0.25 * scale
|
||||||
|
x = (m21 - m12) / scale
|
||||||
|
y = (m02 - m20) / scale
|
||||||
|
z = (m10 - m01) / scale
|
||||||
|
elif m00 > m11 and m00 > m22:
|
||||||
|
scale = math.sqrt(1.0 + m00 - m11 - m22) * 2
|
||||||
|
w = (m21 - m12) / scale
|
||||||
|
x = 0.25 * scale
|
||||||
|
y = (m01 + m10) / scale
|
||||||
|
z = (m02 + m20) / scale
|
||||||
|
elif m11 > m22:
|
||||||
|
scale = math.sqrt(1.0 + m11 - m00 - m22) * 2
|
||||||
|
w = (m02 - m20) / scale
|
||||||
|
x = (m01 + m10) / scale
|
||||||
|
y = 0.25 * scale
|
||||||
|
z = (m12 + m21) / scale
|
||||||
|
else:
|
||||||
|
scale = math.sqrt(1.0 + m22 - m00 - m11) * 2
|
||||||
|
w = (m10 - m01) / scale
|
||||||
|
x = (m02 + m20) / scale
|
||||||
|
y = (m12 + m21) / scale
|
||||||
|
z = 0.25 * scale
|
||||||
|
|
||||||
|
return [round_float(x), round_float(y), round_float(z), round_float(w)]
|
||||||
|
|
||||||
|
|
||||||
|
def round_float(value):
|
||||||
|
return round(value, 6)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as error:
|
||||||
|
print(str(error), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
260
service/scripts/sweep_f22_cobra.py
Normal file
260
service/scripts/sweep_f22_cobra.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import jsbsim_runner
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_AIRCRAFT_ID = "f22cobra"
|
||||||
|
DEFAULT_ALTITUDE_M = 3000
|
||||||
|
DEFAULT_SAMPLE_RATE_HZ = 20
|
||||||
|
DEFAULT_INTEGRATION_RATE_HZ = 120
|
||||||
|
DEFAULT_DURATION_SEC = 5
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
candidates = build_grid(args.mode)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Running {len(candidates)} F-22 cobra candidates "
|
||||||
|
f"({args.mode}, duration={args.duration_sec}s)...",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, parameters in enumerate(candidates, start=1):
|
||||||
|
if index == 1 or index % 10 == 0 or index == len(candidates):
|
||||||
|
print(f"[{index}/{len(candidates)}]", file=sys.stderr)
|
||||||
|
|
||||||
|
result = run_candidate(parameters, args)
|
||||||
|
if result:
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
ranked = sorted(results, key=lambda item: item["score"])
|
||||||
|
top = ranked[: args.top]
|
||||||
|
|
||||||
|
print_summary(top)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(top, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Sweep F-22 JSBSim cobra maneuver parameters."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=["quick", "wide"],
|
||||||
|
default="quick",
|
||||||
|
help="quick is small enough for iteration; wide explores more combinations.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--top", type=int, default=10)
|
||||||
|
parser.add_argument("--duration-sec", type=float, default=DEFAULT_DURATION_SEC)
|
||||||
|
parser.add_argument("--json", action="store_true")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def build_grid(mode):
|
||||||
|
if mode == "wide":
|
||||||
|
entry_speeds = [180, 200, 220, 240, 260]
|
||||||
|
pull_durations = [0.45, 0.6, 0.75, 0.9, 1.05]
|
||||||
|
unload_durations = [0.35, 0.5, 0.7]
|
||||||
|
recovery_elevators = [0.2, 0.35, 0.5, 0.65]
|
||||||
|
recovery_tvc_cmds = [0.0, 0.35, 0.7, 1.0]
|
||||||
|
throttle_cmds = [0.85, 1.0]
|
||||||
|
else:
|
||||||
|
entry_speeds = [200, 220, 240]
|
||||||
|
pull_durations = [0.45, 0.6, 0.75, 0.9]
|
||||||
|
unload_durations = [0.35, 0.5, 0.7]
|
||||||
|
recovery_elevators = [0.25, 0.4, 0.55]
|
||||||
|
recovery_tvc_cmds = [0.0, 0.5, 1.0]
|
||||||
|
throttle_cmds = [1.0]
|
||||||
|
|
||||||
|
grid = []
|
||||||
|
for (
|
||||||
|
speed_mps,
|
||||||
|
pull_duration_sec,
|
||||||
|
unload_duration_sec,
|
||||||
|
recovery_elevator_cmd,
|
||||||
|
recovery_tvc_cmd,
|
||||||
|
throttle_cmd,
|
||||||
|
) in itertools.product(
|
||||||
|
entry_speeds,
|
||||||
|
pull_durations,
|
||||||
|
unload_durations,
|
||||||
|
recovery_elevators,
|
||||||
|
recovery_tvc_cmds,
|
||||||
|
throttle_cmds,
|
||||||
|
):
|
||||||
|
grid.append(
|
||||||
|
{
|
||||||
|
"entrySpeedMps": speed_mps,
|
||||||
|
"pullDurationSec": pull_duration_sec,
|
||||||
|
"unloadDurationSec": unload_duration_sec,
|
||||||
|
"recoveryDurationSec": 2.0,
|
||||||
|
"maxElevatorCmd": -1.0,
|
||||||
|
"recoveryElevatorCmd": recovery_elevator_cmd,
|
||||||
|
"pullTvcCmd": 0.0,
|
||||||
|
"recoveryTvcCmd": recovery_tvc_cmd,
|
||||||
|
"throttleCmd": throttle_cmd,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return grid
|
||||||
|
|
||||||
|
|
||||||
|
def run_candidate(parameters, args):
|
||||||
|
payload = {
|
||||||
|
"operation": "simulate",
|
||||||
|
"aircraftId": DEFAULT_AIRCRAFT_ID,
|
||||||
|
"sampleRateHz": DEFAULT_SAMPLE_RATE_HZ,
|
||||||
|
"integrationRateHz": DEFAULT_INTEGRATION_RATE_HZ,
|
||||||
|
"trim": False,
|
||||||
|
"durationSec": args.duration_sec,
|
||||||
|
"initialState": {
|
||||||
|
"position": [0, DEFAULT_ALTITUDE_M, 0],
|
||||||
|
"velocityMps": parameters["entrySpeedMps"],
|
||||||
|
"headingDeg": 0,
|
||||||
|
},
|
||||||
|
"maneuver": {
|
||||||
|
"type": "cobra",
|
||||||
|
"parameters": {
|
||||||
|
"flightPathAngleDeg": 0,
|
||||||
|
"pullDurationSec": parameters["pullDurationSec"],
|
||||||
|
"unloadDurationSec": parameters["unloadDurationSec"],
|
||||||
|
"recoveryDurationSec": parameters["recoveryDurationSec"],
|
||||||
|
"maxElevatorCmd": parameters["maxElevatorCmd"],
|
||||||
|
"recoveryElevatorCmd": parameters["recoveryElevatorCmd"],
|
||||||
|
"pullTvcCmd": parameters["pullTvcCmd"],
|
||||||
|
"recoveryTvcCmd": parameters["recoveryTvcCmd"],
|
||||||
|
"throttleCmd": parameters["throttleCmd"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
simulation = jsbsim_runner.simulate(payload)
|
||||||
|
except Exception as error:
|
||||||
|
return {
|
||||||
|
"score": 1_000_000,
|
||||||
|
"failed": True,
|
||||||
|
"error": str(error),
|
||||||
|
"parameters": parameters,
|
||||||
|
}
|
||||||
|
|
||||||
|
samples = simulation["samples"]
|
||||||
|
metrics = compute_metrics(samples, parameters)
|
||||||
|
return {
|
||||||
|
"score": round(score(metrics), 6),
|
||||||
|
"failed": False,
|
||||||
|
"parameters": parameters,
|
||||||
|
"metrics": metrics,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_metrics(samples, parameters):
|
||||||
|
first = samples[0]
|
||||||
|
last = samples[-1]
|
||||||
|
max_alpha_sample = max(samples, key=lambda sample: sample.get("alphaDeg", -999))
|
||||||
|
max_pitch_sample = max(samples, key=lambda sample: sample.get("pitchDeg", -999))
|
||||||
|
min_speed_sample = min(samples, key=lambda sample: sample.get("velocityMps", 999999))
|
||||||
|
heading_delta = angle_delta_deg(first.get("headingDeg", 0), last.get("headingDeg", 0))
|
||||||
|
altitude_delta = last["position"][1] - first["position"][1]
|
||||||
|
forward_delta = last["position"][2] - first["position"][2]
|
||||||
|
lateral_delta = last["position"][0] - first["position"][0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"maxAlphaDeg": round_float(max_alpha_sample.get("alphaDeg", 0)),
|
||||||
|
"maxAlphaTimeSec": round_float(max_alpha_sample.get("t", 0)),
|
||||||
|
"maxPitchDeg": round_float(max_pitch_sample.get("pitchDeg", 0)),
|
||||||
|
"maxPitchTimeSec": round_float(max_pitch_sample.get("t", 0)),
|
||||||
|
"endAlphaDeg": round_float(last.get("alphaDeg", 0)),
|
||||||
|
"endPitchDeg": round_float(last.get("pitchDeg", 0)),
|
||||||
|
"minSpeedMps": round_float(min_speed_sample.get("velocityMps", 0)),
|
||||||
|
"endSpeedMps": round_float(last.get("velocityMps", 0)),
|
||||||
|
"altitudeDeltaM": round_float(altitude_delta),
|
||||||
|
"forwardDeltaM": round_float(forward_delta),
|
||||||
|
"lateralDeltaM": round_float(lateral_delta),
|
||||||
|
"headingDeltaDeg": round_float(heading_delta),
|
||||||
|
"entrySpeedMps": parameters["entrySpeedMps"],
|
||||||
|
"maxTvcDeg": round_float(
|
||||||
|
max(
|
||||||
|
abs(sample.get("controlSurfaces", {}).get("tvcDeg", 0))
|
||||||
|
for sample in samples
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"maxThrustNorm": round_float(
|
||||||
|
max(sample.get("fcs", {}).get("thrustNorm", 0) for sample in samples)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def score(metrics):
|
||||||
|
max_alpha = metrics["maxAlphaDeg"]
|
||||||
|
max_pitch = metrics["maxPitchDeg"]
|
||||||
|
end_alpha = abs(metrics["endAlphaDeg"])
|
||||||
|
end_pitch = abs(metrics["endPitchDeg"])
|
||||||
|
altitude_delta = metrics["altitudeDeltaM"]
|
||||||
|
lateral_delta = abs(metrics["lateralDeltaM"])
|
||||||
|
speed_loss = metrics["entrySpeedMps"] - metrics["minSpeedMps"]
|
||||||
|
|
||||||
|
return (
|
||||||
|
abs(max_alpha - 105) * 2.0
|
||||||
|
+ abs(max_pitch - 95) * 1.2
|
||||||
|
+ max(0, 90 - max_alpha) * 8.0
|
||||||
|
+ max(0, max_alpha - 125) * 10.0
|
||||||
|
+ max(0, end_alpha - 25) * 1.5
|
||||||
|
+ max(0, end_pitch - 35) * 0.8
|
||||||
|
+ max(0, 35 - speed_loss) * 0.8
|
||||||
|
+ lateral_delta * 0.15
|
||||||
|
+ max(0, -altitude_delta) * 0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def print_summary(results):
|
||||||
|
print("")
|
||||||
|
print("rank score speed pull unload recElev recTvc maxA tA maxP endA endP minV dAlt dFwd dHead")
|
||||||
|
for index, result in enumerate(results, start=1):
|
||||||
|
params = result["parameters"]
|
||||||
|
metrics = result.get("metrics", {})
|
||||||
|
if result.get("failed"):
|
||||||
|
print(f"{index:>4} FAILED {result.get('error')}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{index:>4} "
|
||||||
|
f"{result['score']:>6.1f} "
|
||||||
|
f"{params['entrySpeedMps']:>5.0f} "
|
||||||
|
f"{params['pullDurationSec']:>4.2f} "
|
||||||
|
f"{params['unloadDurationSec']:>5.2f} "
|
||||||
|
f"{params['recoveryElevatorCmd']:>7.2f} "
|
||||||
|
f"{params['recoveryTvcCmd']:>6.2f} "
|
||||||
|
f"{metrics['maxAlphaDeg']:>5.1f} "
|
||||||
|
f"{metrics['maxAlphaTimeSec']:>3.1f} "
|
||||||
|
f"{metrics['maxPitchDeg']:>5.1f} "
|
||||||
|
f"{metrics['endAlphaDeg']:>5.1f} "
|
||||||
|
f"{metrics['endPitchDeg']:>5.1f} "
|
||||||
|
f"{metrics['minSpeedMps']:>5.1f} "
|
||||||
|
f"{metrics['altitudeDeltaM']:>5.0f} "
|
||||||
|
f"{metrics['forwardDeltaM']:>5.0f} "
|
||||||
|
f"{metrics['headingDeltaDeg']:>5.1f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def angle_delta_deg(start, end):
|
||||||
|
return ((end - start + 540) % 360) - 180
|
||||||
|
|
||||||
|
|
||||||
|
def round_float(value):
|
||||||
|
return round(float(value), 6) if math.isfinite(float(value)) else value
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
171
service/src/backends/jsbsimBackend.js
Normal file
171
service/src/backends/jsbsimBackend.js
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
const DEFAULT_RUNNER = resolve(serviceRoot, "scripts", "jsbsim_runner.py");
|
||||||
|
const runnerPath = process.env.JSBSIM_RUNNER ?? DEFAULT_RUNNER;
|
||||||
|
const pythonCommand = process.env.JSBSIM_PYTHON ?? "python";
|
||||||
|
|
||||||
|
export const backendInfo = {
|
||||||
|
id: "jsbsim",
|
||||||
|
name: "JSBSim",
|
||||||
|
ready: true,
|
||||||
|
runner: runnerPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AIRCRAFT = [
|
||||||
|
{
|
||||||
|
id: "f22cobra-jsbsim",
|
||||||
|
name: "F-22 Cobra Research JSBSim",
|
||||||
|
source: "JSBSim",
|
||||||
|
capabilities: {
|
||||||
|
basic6dof: true,
|
||||||
|
highAlpha: true,
|
||||||
|
postStall: "experimental",
|
||||||
|
thrustVectoring: true,
|
||||||
|
requiresNoTrimForManeuverResearch: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "f22-jsbsim",
|
||||||
|
name: "F-22 JSBSim",
|
||||||
|
source: "JSBSim",
|
||||||
|
capabilities: {
|
||||||
|
basic6dof: true,
|
||||||
|
highAlpha: true,
|
||||||
|
postStall: "experimental",
|
||||||
|
thrustVectoring: true,
|
||||||
|
requiresNoTrimForManeuverResearch: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "f16-jsbsim",
|
||||||
|
name: "F-16 JSBSim",
|
||||||
|
source: "JSBSim",
|
||||||
|
capabilities: {
|
||||||
|
basic6dof: true,
|
||||||
|
highAlpha: "limited",
|
||||||
|
postStall: false,
|
||||||
|
thrustVectoring: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c172p-jsbsim",
|
||||||
|
name: "Cessna 172P JSBSim",
|
||||||
|
source: "JSBSim",
|
||||||
|
capabilities: {
|
||||||
|
basic6dof: true,
|
||||||
|
highAlpha: false,
|
||||||
|
postStall: false,
|
||||||
|
thrustVectoring: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function trimInitialState(request) {
|
||||||
|
validateTrimRequest(request);
|
||||||
|
|
||||||
|
return runJsbsimRunner({
|
||||||
|
operation: "trim",
|
||||||
|
aircraftId: request.aircraftId,
|
||||||
|
sampleRateHz: 30,
|
||||||
|
integrationRateHz: numberOrDefault(request.integrationRateHz, 120),
|
||||||
|
trim: request,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function simulateManeuver(request) {
|
||||||
|
validateSimulateRequest(request);
|
||||||
|
|
||||||
|
return runJsbsimRunner({
|
||||||
|
operation: "simulate",
|
||||||
|
aircraftId: request.aircraftId.replace("-jsbsim", ""),
|
||||||
|
sampleRateHz: numberOrDefault(request.sampleRateHz, 30),
|
||||||
|
integrationRateHz: numberOrDefault(request.integrationRateHz, 120),
|
||||||
|
durationSec: numberOrDefault(request.durationSec, 10),
|
||||||
|
trim: request.trim,
|
||||||
|
initialState: request.initialState,
|
||||||
|
maneuver: request.maneuver,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runJsbsimRunner(payload) {
|
||||||
|
const result = spawnSync(pythonCommand, [runnerPath], {
|
||||||
|
input: JSON.stringify(payload),
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 20 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(
|
||||||
|
`Unable to run JSBSim runner with "${pythonCommand}". Install Python and jsbsim, or set JSBSIM_PYTHON/JSBSIM_RUNNER. ${result.error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
if (result.status === 9009) {
|
||||||
|
throw new Error(
|
||||||
|
`Unable to run JSBSim runner with "${pythonCommand}". Python is not available on PATH; install Python and jsbsim, or set JSBSIM_PYTHON to a Python executable.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`JSBSim runner failed with exit code ${result.status}: ${result.stderr.trim()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(extractJson(result.stdout));
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
backend: backendInfo.id,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new Error(`JSBSim runner did not return valid JSON: ${result.stdout}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJson(stdout) {
|
||||||
|
const firstObject = stdout.indexOf("{");
|
||||||
|
if (firstObject < 0) {
|
||||||
|
throw new Error("No JSON payload found.");
|
||||||
|
}
|
||||||
|
return stdout.slice(firstObject).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTrimRequest(request) {
|
||||||
|
if (!request || typeof request !== "object") {
|
||||||
|
throw new Error("Request body must be a JSON object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AIRCRAFT.some((aircraft) => aircraft.id === request.aircraftId)) {
|
||||||
|
throw new Error(`Unknown aircraftId: ${request.aircraftId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSimulateRequest(request) {
|
||||||
|
if (!request || typeof request !== "object") {
|
||||||
|
throw new Error("Request body must be a JSON object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AIRCRAFT.some((aircraft) => aircraft.id === request.aircraftId)) {
|
||||||
|
throw new Error(`Unknown aircraftId: ${request.aircraftId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.initialState || typeof request.initialState !== "object") {
|
||||||
|
throw new Error("initialState is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const maneuverType = request.maneuver?.type;
|
||||||
|
const supported = new Set(["straight", "climb", "descent", "control-script", "cobra"]);
|
||||||
|
if (!supported.has(maneuverType)) {
|
||||||
|
throw new Error(
|
||||||
|
`JSBSim minimal backend supports straight/climb/descent/control-script/cobra only. Unsupported maneuver.type: ${maneuverType}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrDefault(value, fallback) {
|
||||||
|
return Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
472
service/src/backends/mockBackend.js
Normal file
472
service/src/backends/mockBackend.js
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
const EARTH_GRAVITY_MPS2 = 9.80665;
|
||||||
|
const SPEED_OF_SOUND_SEA_LEVEL_MPS = 340.29;
|
||||||
|
|
||||||
|
export const backendInfo = {
|
||||||
|
id: "mock",
|
||||||
|
name: "Mock Dynamics",
|
||||||
|
ready: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AIRCRAFT = [
|
||||||
|
{
|
||||||
|
id: "f16-mock",
|
||||||
|
name: "F-16 Mock Dynamics",
|
||||||
|
source: "Mock",
|
||||||
|
capabilities: {
|
||||||
|
basic6dof: false,
|
||||||
|
highAlpha: "visual-only",
|
||||||
|
postStall: "visual-only",
|
||||||
|
thrustVectoring: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function trimInitialState(request) {
|
||||||
|
const altitudeM = numberOrDefault(request.altitudeM, 1000);
|
||||||
|
const speedMps = numberOrDefault(request.speedMps, 120);
|
||||||
|
const headingDeg = normalizeHeading(numberOrDefault(request.headingDeg, 0));
|
||||||
|
const flightPathAngleDeg = numberOrDefault(request.flightPathAngleDeg, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
backend: backendInfo.id,
|
||||||
|
state: {
|
||||||
|
position: [0, altitudeM, 0],
|
||||||
|
rotation: eulerToQuat(0, flightPathAngleDeg, -headingDeg),
|
||||||
|
velocityMps: speedMps,
|
||||||
|
headingDeg,
|
||||||
|
alphaDeg: 3.2,
|
||||||
|
betaDeg: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function simulateManeuver(request) {
|
||||||
|
validateSimulateRequest(request);
|
||||||
|
|
||||||
|
const sampleRateHz = clamp(numberOrDefault(request.sampleRateHz, 30), 1, 240);
|
||||||
|
const integrationRateHz = clamp(numberOrDefault(request.integrationRateHz, 120), 1, 1000);
|
||||||
|
const durationSec = clamp(numberOrDefault(request.durationSec, 10), 0.1, 3600);
|
||||||
|
const totalSamples = Math.floor(durationSec * sampleRateHz) + 1;
|
||||||
|
const samples = [];
|
||||||
|
|
||||||
|
const initial = request.initialState;
|
||||||
|
const startPosition = vec3OrDefault(initial.position, [0, 1000, 0]);
|
||||||
|
const startVelocity = numberOrDefault(initial.velocityMps, 120);
|
||||||
|
const startHeading = normalizeHeading(numberOrDefault(initial.headingDeg, 0));
|
||||||
|
const maneuver = request.maneuver ?? { type: "straight", parameters: {} };
|
||||||
|
const parameters = maneuver.parameters ?? {};
|
||||||
|
|
||||||
|
for (let index = 0; index < totalSamples; index += 1) {
|
||||||
|
const t = Math.min(index / sampleRateHz, durationSec);
|
||||||
|
samples.push(
|
||||||
|
sampleAtTime({
|
||||||
|
t,
|
||||||
|
maneuverType: maneuver.type,
|
||||||
|
parameters,
|
||||||
|
startPosition,
|
||||||
|
startVelocity,
|
||||||
|
startHeading,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `sim_${randomUUID()}`,
|
||||||
|
backend: backendInfo.id,
|
||||||
|
aircraftId: request.aircraftId,
|
||||||
|
sampleRateHz,
|
||||||
|
integrationRateHz,
|
||||||
|
durationSec,
|
||||||
|
coordinateFrame: "local-eun",
|
||||||
|
headingConvention:
|
||||||
|
"true heading: 0 deg north, clockwise positive, counter-clockwise negative",
|
||||||
|
samples,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleAtTime({
|
||||||
|
t,
|
||||||
|
maneuverType,
|
||||||
|
parameters,
|
||||||
|
startPosition,
|
||||||
|
startVelocity,
|
||||||
|
startHeading,
|
||||||
|
}) {
|
||||||
|
const speedMps = Math.max(1, numberOrDefault(parameters.speedMps, startVelocity));
|
||||||
|
let headingDeg = startHeading;
|
||||||
|
let east = startPosition[0];
|
||||||
|
let up = startPosition[1];
|
||||||
|
let north = startPosition[2];
|
||||||
|
let pitchDeg = 0;
|
||||||
|
let rollDeg = 0;
|
||||||
|
let loadFactor = 1;
|
||||||
|
let alphaDeg = 3.2;
|
||||||
|
let verticalSpeedMps = 0;
|
||||||
|
|
||||||
|
if (maneuverType === "level-turn") {
|
||||||
|
const turnRateDegPerSec = numberOrDefault(
|
||||||
|
parameters.turnRateDegPerSec,
|
||||||
|
inferTurnRate(parameters.bankDeg, speedMps)
|
||||||
|
);
|
||||||
|
const turnRateRadPerSec = degToRad(turnRateDegPerSec);
|
||||||
|
|
||||||
|
headingDeg = normalizeHeading(startHeading + turnRateDegPerSec * t);
|
||||||
|
rollDeg = numberOrDefault(
|
||||||
|
parameters.bankDeg,
|
||||||
|
inferBankDeg(turnRateDegPerSec, speedMps)
|
||||||
|
);
|
||||||
|
loadFactor = 1 / Math.max(0.2, Math.cos(degToRad(Math.abs(rollDeg))));
|
||||||
|
alphaDeg = 3.2 + Math.max(0, loadFactor - 1) * 1.4;
|
||||||
|
|
||||||
|
if (Math.abs(turnRateRadPerSec) < 0.000001) {
|
||||||
|
const straight = headingDistance(startHeading, speedMps * t);
|
||||||
|
east += straight[0];
|
||||||
|
north += straight[1];
|
||||||
|
} else {
|
||||||
|
const radiusM = speedMps / turnRateRadPerSec;
|
||||||
|
const initialHeadingRad = degToRad(startHeading);
|
||||||
|
const currentHeadingRad = initialHeadingRad + turnRateRadPerSec * t;
|
||||||
|
east += radiusM * (Math.cos(initialHeadingRad) - Math.cos(currentHeadingRad));
|
||||||
|
north += radiusM * (Math.sin(currentHeadingRad) - Math.sin(initialHeadingRad));
|
||||||
|
}
|
||||||
|
} else if (maneuverType === "cobra" || maneuverType === "control-script") {
|
||||||
|
const controls = controlInputsAtTime(maneuverType, parameters, t);
|
||||||
|
const profile = postStallProfile(maneuverType, parameters, t, startVelocity);
|
||||||
|
const distance = profile.forwardDistanceM;
|
||||||
|
const horizontal = headingDistance(startHeading, distance);
|
||||||
|
|
||||||
|
east += horizontal[0];
|
||||||
|
north += horizontal[1];
|
||||||
|
up += profile.altitudeDeltaM;
|
||||||
|
pitchDeg = profile.pitchDeg;
|
||||||
|
alphaDeg = profile.alphaDeg;
|
||||||
|
loadFactor = profile.loadFactor;
|
||||||
|
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
position: [round(east), round(up), round(north)],
|
||||||
|
rotation: eulerToQuat(rollDeg, pitchDeg, -headingDeg),
|
||||||
|
velocityMps: round(profile.speedMps),
|
||||||
|
headingDeg: round(headingDeg),
|
||||||
|
pitchDeg: round(pitchDeg),
|
||||||
|
rollDeg: round(rollDeg),
|
||||||
|
alphaDeg: round(alphaDeg),
|
||||||
|
betaDeg: 0,
|
||||||
|
loadFactor: round(loadFactor),
|
||||||
|
mach: round(profile.speedMps / SPEED_OF_SOUND_SEA_LEVEL_MPS),
|
||||||
|
qbar: round(0.5 * 1.225 * profile.speedMps * profile.speedMps),
|
||||||
|
verticalSpeedMps: round(profile.verticalSpeedMps),
|
||||||
|
pitchRateDegS: round(profile.pitchRateDegS),
|
||||||
|
rollRateDegS: 0,
|
||||||
|
yawRateDegS: 0,
|
||||||
|
throttle: controls.throttle,
|
||||||
|
controlInputs: controls,
|
||||||
|
controlSurfaces: {
|
||||||
|
elevatorDeg: round(controls.elevator * 25),
|
||||||
|
aileronDeg: round(controls.aileron * 21),
|
||||||
|
rudderDeg: round(controls.rudder * 30),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const flightPathAngleDeg =
|
||||||
|
maneuverType === "climb"
|
||||||
|
? Math.abs(numberOrDefault(parameters.flightPathAngleDeg, 8))
|
||||||
|
: maneuverType === "descent"
|
||||||
|
? -Math.abs(numberOrDefault(parameters.flightPathAngleDeg, 8))
|
||||||
|
: numberOrDefault(parameters.flightPathAngleDeg, 0);
|
||||||
|
const horizontalSpeed = speedMps * Math.cos(degToRad(flightPathAngleDeg));
|
||||||
|
const verticalSpeed = speedMps * Math.sin(degToRad(flightPathAngleDeg));
|
||||||
|
const distance = horizontalSpeed * t;
|
||||||
|
const horizontal = headingDistance(startHeading, distance);
|
||||||
|
|
||||||
|
east += horizontal[0];
|
||||||
|
north += horizontal[1];
|
||||||
|
up += verticalSpeed * t;
|
||||||
|
pitchDeg = flightPathAngleDeg;
|
||||||
|
alphaDeg = 3.2 + Math.max(0, flightPathAngleDeg) * 0.08;
|
||||||
|
verticalSpeedMps = verticalSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
position: [round(east), round(up), round(north)],
|
||||||
|
rotation: eulerToQuat(rollDeg, pitchDeg, -headingDeg),
|
||||||
|
velocityMps: round(speedMps),
|
||||||
|
headingDeg: round(headingDeg),
|
||||||
|
pitchDeg: round(pitchDeg),
|
||||||
|
rollDeg: round(rollDeg),
|
||||||
|
alphaDeg: round(alphaDeg),
|
||||||
|
betaDeg: 0,
|
||||||
|
loadFactor: round(loadFactor),
|
||||||
|
mach: round(speedMps / SPEED_OF_SOUND_SEA_LEVEL_MPS),
|
||||||
|
qbar: round(0.5 * 1.225 * speedMps * speedMps),
|
||||||
|
verticalSpeedMps: round(verticalSpeedMps),
|
||||||
|
pitchRateDegS: 0,
|
||||||
|
rollRateDegS: 0,
|
||||||
|
yawRateDegS: 0,
|
||||||
|
throttle: 0.82,
|
||||||
|
controlInputs: {
|
||||||
|
elevator: 0,
|
||||||
|
aileron: 0,
|
||||||
|
rudder: 0,
|
||||||
|
throttle: 0.82,
|
||||||
|
},
|
||||||
|
controlSurfaces: {
|
||||||
|
elevatorDeg: round(pitchDeg * 0.15),
|
||||||
|
aileronDeg: round(rollDeg * 0.1),
|
||||||
|
rudderDeg: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSimulateRequest(request) {
|
||||||
|
if (!request || typeof request !== "object") {
|
||||||
|
throw new Error("Request body must be a JSON object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AIRCRAFT.some((aircraft) => aircraft.id === request.aircraftId)) {
|
||||||
|
throw new Error(`Unknown aircraftId: ${request.aircraftId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.initialState || typeof request.initialState !== "object") {
|
||||||
|
throw new Error("initialState is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.maneuver || typeof request.maneuver !== "object") {
|
||||||
|
throw new Error("maneuver is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedManeuvers = new Set([
|
||||||
|
"straight",
|
||||||
|
"climb",
|
||||||
|
"descent",
|
||||||
|
"level-turn",
|
||||||
|
"control-script",
|
||||||
|
"cobra",
|
||||||
|
]);
|
||||||
|
if (!allowedManeuvers.has(request.maneuver.type)) {
|
||||||
|
throw new Error(`Unsupported maneuver.type: ${request.maneuver.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function controlInputsAtTime(maneuverType, parameters, t) {
|
||||||
|
const timeline =
|
||||||
|
maneuverType === "cobra" ? createCobraTimeline(parameters) : parameters.timeline;
|
||||||
|
if (!Array.isArray(timeline) || timeline.length === 0) {
|
||||||
|
return { elevator: 0, aileron: 0, rudder: 0, throttle: 0.82 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = timeline
|
||||||
|
.filter((point) => point && typeof point === "object")
|
||||||
|
.toSorted((a, b) => numberOrDefault(a.t, 0) - numberOrDefault(b.t, 0));
|
||||||
|
|
||||||
|
if (points.length === 0 || t <= numberOrDefault(points[0].t, 0)) {
|
||||||
|
return normalizeControlPoint(points[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 1; index < points.length; index += 1) {
|
||||||
|
const previous = points[index - 1];
|
||||||
|
const current = points[index];
|
||||||
|
const previousT = numberOrDefault(previous.t, 0);
|
||||||
|
const currentT = numberOrDefault(current.t, previousT);
|
||||||
|
if (t <= currentT) {
|
||||||
|
const amount = currentT <= previousT ? 0 : (t - previousT) / (currentT - previousT);
|
||||||
|
return interpolateControls(
|
||||||
|
normalizeControlPoint(previous),
|
||||||
|
normalizeControlPoint(current),
|
||||||
|
amount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeControlPoint(points.at(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCobraTimeline(parameters) {
|
||||||
|
const pullDuration = Math.max(0.1, numberOrDefault(parameters.pullDurationSec, 0.9));
|
||||||
|
const unloadDuration = Math.max(0.1, numberOrDefault(parameters.unloadDurationSec, 0.7));
|
||||||
|
const recoveryDuration = Math.max(0.1, numberOrDefault(parameters.recoveryDurationSec, 2.0));
|
||||||
|
const maxElevatorCmd = clamp(numberOrDefault(parameters.maxElevatorCmd, -1), -1, 1);
|
||||||
|
const recoveryElevatorCmd = clamp(numberOrDefault(parameters.recoveryElevatorCmd, 0.35), -1, 1);
|
||||||
|
const throttleCmd = clamp(numberOrDefault(parameters.throttleCmd, 1), 0, 1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ t: 0, elevator: 0, aileron: 0, rudder: 0, throttle: throttleCmd },
|
||||||
|
{ t: 0.2, elevator: maxElevatorCmd, aileron: 0, rudder: 0, throttle: throttleCmd },
|
||||||
|
{
|
||||||
|
t: 0.2 + pullDuration,
|
||||||
|
elevator: 0,
|
||||||
|
aileron: 0,
|
||||||
|
rudder: 0,
|
||||||
|
throttle: throttleCmd,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
t: 0.2 + pullDuration + unloadDuration,
|
||||||
|
elevator: recoveryElevatorCmd,
|
||||||
|
aileron: 0,
|
||||||
|
rudder: 0,
|
||||||
|
throttle: throttleCmd,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
t: 0.2 + pullDuration + unloadDuration + recoveryDuration,
|
||||||
|
elevator: 0,
|
||||||
|
aileron: 0,
|
||||||
|
rudder: 0,
|
||||||
|
throttle: throttleCmd,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function postStallProfile(maneuverType, parameters, t, startVelocity) {
|
||||||
|
if (maneuverType === "control-script") {
|
||||||
|
const controls = controlInputsAtTime(maneuverType, parameters, t);
|
||||||
|
const pitchDeg = controls.elevator * 35;
|
||||||
|
const speedMps = Math.max(30, startVelocity - Math.abs(pitchDeg) * 0.4);
|
||||||
|
return {
|
||||||
|
pitchDeg,
|
||||||
|
alphaDeg: 3.2 + Math.max(0, pitchDeg) * 0.8,
|
||||||
|
speedMps,
|
||||||
|
forwardDistanceM: speedMps * t,
|
||||||
|
altitudeDeltaM: Math.sin(degToRad(pitchDeg)) * speedMps * t * 0.15,
|
||||||
|
verticalSpeedMps: Math.sin(degToRad(pitchDeg)) * speedMps,
|
||||||
|
pitchRateDegS: controls.elevator * 45,
|
||||||
|
loadFactor: 1 + Math.max(0, controls.elevator) * 1.8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pullDuration = Math.max(0.1, numberOrDefault(parameters.pullDurationSec, 0.9));
|
||||||
|
const unloadDuration = Math.max(0.1, numberOrDefault(parameters.unloadDurationSec, 0.7));
|
||||||
|
const recoveryDuration = Math.max(0.1, numberOrDefault(parameters.recoveryDurationSec, 2.0));
|
||||||
|
const peakPitchDeg = numberOrDefault(parameters.peakPitchDeg, 105);
|
||||||
|
const tPullEnd = 0.2 + pullDuration;
|
||||||
|
const tUnloadEnd = tPullEnd + unloadDuration;
|
||||||
|
const tRecoveryEnd = tUnloadEnd + recoveryDuration;
|
||||||
|
|
||||||
|
let pitchDeg;
|
||||||
|
if (t <= 0.2) {
|
||||||
|
pitchDeg = 0;
|
||||||
|
} else if (t <= tPullEnd) {
|
||||||
|
pitchDeg = smoothstep((t - 0.2) / pullDuration) * peakPitchDeg;
|
||||||
|
} else if (t <= tUnloadEnd) {
|
||||||
|
pitchDeg = peakPitchDeg - smoothstep((t - tPullEnd) / unloadDuration) * 35;
|
||||||
|
} else if (t <= tRecoveryEnd) {
|
||||||
|
pitchDeg = (peakPitchDeg - 35) * (1 - smoothstep((t - tUnloadEnd) / recoveryDuration));
|
||||||
|
} else {
|
||||||
|
pitchDeg = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const speedDrop = Math.min(startVelocity * 0.62, Math.max(0, pitchDeg) * 0.9);
|
||||||
|
const speedMps = Math.max(35, startVelocity - speedDrop);
|
||||||
|
const forwardFactor = Math.max(0.18, Math.cos(degToRad(Math.min(Math.abs(pitchDeg), 82))));
|
||||||
|
const forwardDistanceM = speedMps * t * forwardFactor;
|
||||||
|
const altitudeDeltaM = Math.sin(degToRad(Math.min(pitchDeg, 75))) * speedMps * Math.min(t, 3.5) * 0.18;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pitchDeg,
|
||||||
|
alphaDeg: Math.max(3.2, pitchDeg * 0.95),
|
||||||
|
speedMps,
|
||||||
|
forwardDistanceM,
|
||||||
|
altitudeDeltaM,
|
||||||
|
verticalSpeedMps: Math.sin(degToRad(Math.min(pitchDeg, 75))) * speedMps,
|
||||||
|
pitchRateDegS: t <= tPullEnd ? peakPitchDeg / pullDuration : -peakPitchDeg / recoveryDuration,
|
||||||
|
loadFactor: 1 + Math.max(0, pitchDeg / peakPitchDeg) * 2.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeControlPoint(point = {}) {
|
||||||
|
return {
|
||||||
|
elevator: clamp(numberOrDefault(point.elevator, 0), -1, 1),
|
||||||
|
aileron: clamp(numberOrDefault(point.aileron, 0), -1, 1),
|
||||||
|
rudder: clamp(numberOrDefault(point.rudder, 0), -1, 1),
|
||||||
|
throttle: clamp(numberOrDefault(point.throttle, 0.82), 0, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateControls(start, end, amount) {
|
||||||
|
return {
|
||||||
|
elevator: round(start.elevator + (end.elevator - start.elevator) * amount),
|
||||||
|
aileron: round(start.aileron + (end.aileron - start.aileron) * amount),
|
||||||
|
rudder: round(start.rudder + (end.rudder - start.rudder) * amount),
|
||||||
|
throttle: round(start.throttle + (end.throttle - start.throttle) * amount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothstep(value) {
|
||||||
|
const x = clamp(value, 0, 1);
|
||||||
|
return x * x * (3 - 2 * x);
|
||||||
|
}
|
||||||
|
|
||||||
|
function headingDistance(headingDeg, distanceM) {
|
||||||
|
const headingRad = degToRad(headingDeg);
|
||||||
|
return [Math.sin(headingRad) * distanceM, Math.cos(headingRad) * distanceM];
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferTurnRate(bankDeg, speedMps) {
|
||||||
|
const bankRad = degToRad(numberOrDefault(bankDeg, 30));
|
||||||
|
return radToDeg((EARTH_GRAVITY_MPS2 * Math.tan(bankRad)) / Math.max(1, speedMps));
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferBankDeg(turnRateDegPerSec, speedMps) {
|
||||||
|
const turnRateRadPerSec = degToRad(turnRateDegPerSec);
|
||||||
|
return radToDeg(Math.atan((turnRateRadPerSec * speedMps) / EARTH_GRAVITY_MPS2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function eulerToQuat(rollDeg, pitchDeg, yawDeg) {
|
||||||
|
const roll = degToRad(rollDeg);
|
||||||
|
const pitch = degToRad(pitchDeg);
|
||||||
|
const yaw = degToRad(yawDeg);
|
||||||
|
const cy = Math.cos(yaw * 0.5);
|
||||||
|
const sy = Math.sin(yaw * 0.5);
|
||||||
|
const cp = Math.cos(pitch * 0.5);
|
||||||
|
const sp = Math.sin(pitch * 0.5);
|
||||||
|
const cr = Math.cos(roll * 0.5);
|
||||||
|
const sr = Math.sin(roll * 0.5);
|
||||||
|
|
||||||
|
return [
|
||||||
|
round(sr * cp * cy - cr * sp * sy),
|
||||||
|
round(cr * sp * cy + sr * cp * sy),
|
||||||
|
round(cr * cp * sy - sr * sp * cy),
|
||||||
|
round(cr * cp * cy + sr * sp * sy),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeading(value) {
|
||||||
|
let heading = numberOrDefault(value, 0) % 360;
|
||||||
|
if (heading > 180) {
|
||||||
|
heading -= 360;
|
||||||
|
}
|
||||||
|
if (heading <= -180) {
|
||||||
|
heading += 360;
|
||||||
|
}
|
||||||
|
return heading;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vec3OrDefault(value, fallback) {
|
||||||
|
if (!Array.isArray(value) || value.length !== 3) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return value.map((item, index) => numberOrDefault(item, fallback[index]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrDefault(value, fallback) {
|
||||||
|
return Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function degToRad(value) {
|
||||||
|
return (value * Math.PI) / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function radToDeg(value) {
|
||||||
|
return (value * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value) {
|
||||||
|
return Math.round(value * 1000000) / 1000000;
|
||||||
|
}
|
||||||
27
service/src/loadLocalEnv.js
Normal file
27
service/src/loadLocalEnv.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const envPath = resolve(serviceRoot, ".env.local");
|
||||||
|
|
||||||
|
if (existsSync(envPath)) {
|
||||||
|
const lines = readFileSync(envPath, "utf8").split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const equalsIndex = trimmed.indexOf("=");
|
||||||
|
if (equalsIndex <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, equalsIndex).trim();
|
||||||
|
const value = trimmed.slice(equalsIndex + 1).trim();
|
||||||
|
if (!process.env[key]) {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
service/src/server.js
Normal file
116
service/src/server.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import "./loadLocalEnv.js";
|
||||||
|
import http from "node:http";
|
||||||
|
import {
|
||||||
|
AIRCRAFT,
|
||||||
|
getBackendInfo,
|
||||||
|
simulateManeuver,
|
||||||
|
trimInitialState,
|
||||||
|
} from "./simulator.js";
|
||||||
|
|
||||||
|
const PORT = Number.parseInt(process.env.PORT ?? "4317", 10);
|
||||||
|
const HOST = process.env.HOST ?? "127.0.0.1";
|
||||||
|
const SERVICE_NAME = "local-flight-sim";
|
||||||
|
const VERSION = "0.1.0";
|
||||||
|
|
||||||
|
const server = http.createServer(async (request, response) => {
|
||||||
|
try {
|
||||||
|
if (request.method === "OPTIONS") {
|
||||||
|
return sendJson(response, 204, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${HOST}:${PORT}`}`);
|
||||||
|
|
||||||
|
if (request.method === "GET" && url.pathname === "/health") {
|
||||||
|
return sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
version: VERSION,
|
||||||
|
backend: getBackendInfo()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "GET" && url.pathname === "/aircraft") {
|
||||||
|
return sendJson(response, 200, { aircraft: AIRCRAFT });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && url.pathname === "/trim") {
|
||||||
|
const body = await readJson(request);
|
||||||
|
return sendJson(response, 200, trimInitialState(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && url.pathname === "/simulate") {
|
||||||
|
const body = await readJson(request);
|
||||||
|
return sendJson(response, 200, simulateManeuver(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && url.pathname === "/cancel") {
|
||||||
|
const body = await readJson(request);
|
||||||
|
return sendJson(response, 200, {
|
||||||
|
success: true,
|
||||||
|
id: body.id ?? null,
|
||||||
|
cancelled: false,
|
||||||
|
message: "No long-running simulation worker is active in the mock service."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(response, 404, {
|
||||||
|
error: "not_found",
|
||||||
|
message: `${request.method} ${url.pathname} is not implemented.`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return sendJson(response, 400, {
|
||||||
|
error: "bad_request",
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
console.log(`${SERVICE_NAME} ${VERSION} listening at http://${HOST}:${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendJson(response, statusCode, payload) {
|
||||||
|
response.writeHead(statusCode, {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
"Content-Type": "application/json; charset=utf-8"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payload === null) {
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.end(JSON.stringify(payload, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(request) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let raw = "";
|
||||||
|
|
||||||
|
request.setEncoding("utf8");
|
||||||
|
request.on("data", (chunk) => {
|
||||||
|
raw += chunk;
|
||||||
|
if (raw.length > 1_000_000) {
|
||||||
|
reject(new Error("Request body is too large."));
|
||||||
|
request.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on("end", () => {
|
||||||
|
if (raw.trim() === "") {
|
||||||
|
resolve({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
reject(new Error("Request body must be valid JSON."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
30
service/src/simulator.js
Normal file
30
service/src/simulator.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as jsbsimBackend from "./backends/jsbsimBackend.js";
|
||||||
|
import * as mockBackend from "./backends/mockBackend.js";
|
||||||
|
|
||||||
|
const BACKENDS = {
|
||||||
|
mock: mockBackend,
|
||||||
|
jsbsim: jsbsimBackend,
|
||||||
|
};
|
||||||
|
|
||||||
|
const backendId = process.env.SIM_BACKEND ?? "mock";
|
||||||
|
const selectedBackend = BACKENDS[backendId] ?? mockBackend;
|
||||||
|
|
||||||
|
if (!BACKENDS[backendId]) {
|
||||||
|
console.warn(
|
||||||
|
`[simulator] Unknown SIM_BACKEND="${backendId}". Falling back to mock.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AIRCRAFT = selectedBackend.AIRCRAFT;
|
||||||
|
|
||||||
|
export function getBackendInfo() {
|
||||||
|
return selectedBackend.backendInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimInitialState(request) {
|
||||||
|
return selectedBackend.trimInitialState(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function simulateManeuver(request) {
|
||||||
|
return selectedBackend.simulateManeuver(request);
|
||||||
|
}
|
||||||
117
service/src/simulator.test.js
Normal file
117
service/src/simulator.test.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
process.env.SIM_BACKEND = "mock";
|
||||||
|
import { getBackendInfo, simulateManeuver, trimInitialState } from "./simulator.js";
|
||||||
|
|
||||||
|
assert.equal(getBackendInfo().id, "mock");
|
||||||
|
|
||||||
|
const trim = trimInitialState({
|
||||||
|
aircraftId: "f16-mock",
|
||||||
|
altitudeM: 1000,
|
||||||
|
speedMps: 120,
|
||||||
|
headingDeg: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(trim.success, true);
|
||||||
|
assert.deepEqual(trim.state.position, [0, 1000, 0]);
|
||||||
|
assert.equal(trim.state.headingDeg, 0);
|
||||||
|
|
||||||
|
const straight = simulateManeuver({
|
||||||
|
aircraftId: "f16-mock",
|
||||||
|
sampleRateHz: 10,
|
||||||
|
durationSec: 1,
|
||||||
|
initialState: {
|
||||||
|
position: [0, 1000, 0],
|
||||||
|
velocityMps: 100,
|
||||||
|
headingDeg: 0
|
||||||
|
},
|
||||||
|
maneuver: {
|
||||||
|
type: "straight",
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(straight.samples.length, 11);
|
||||||
|
assert.equal(straight.integrationRateHz, 120);
|
||||||
|
assert.equal(straight.samples.at(-1).position[0], 0);
|
||||||
|
assert.equal(straight.samples.at(-1).position[2], 100);
|
||||||
|
|
||||||
|
const eastbound = simulateManeuver({
|
||||||
|
aircraftId: "f16-mock",
|
||||||
|
sampleRateHz: 10,
|
||||||
|
durationSec: 1,
|
||||||
|
initialState: {
|
||||||
|
position: [0, 1000, 0],
|
||||||
|
velocityMps: 100,
|
||||||
|
headingDeg: 90
|
||||||
|
},
|
||||||
|
maneuver: {
|
||||||
|
type: "straight",
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(eastbound.samples.at(-1).position[0], 100);
|
||||||
|
assert.equal(eastbound.samples.at(-1).position[2], 0);
|
||||||
|
|
||||||
|
const leftTurn = simulateManeuver({
|
||||||
|
aircraftId: "f16-mock",
|
||||||
|
sampleRateHz: 1,
|
||||||
|
durationSec: 2,
|
||||||
|
initialState: {
|
||||||
|
position: [0, 1000, 0],
|
||||||
|
velocityMps: 120,
|
||||||
|
headingDeg: 0
|
||||||
|
},
|
||||||
|
maneuver: {
|
||||||
|
type: "level-turn",
|
||||||
|
parameters: {
|
||||||
|
turnRateDegPerSec: -10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(leftTurn.samples.at(-1).headingDeg, -20);
|
||||||
|
|
||||||
|
const controlScript = simulateManeuver({
|
||||||
|
aircraftId: "f16-mock",
|
||||||
|
sampleRateHz: 10,
|
||||||
|
durationSec: 1,
|
||||||
|
initialState: {
|
||||||
|
position: [0, 1000, 0],
|
||||||
|
velocityMps: 100,
|
||||||
|
headingDeg: 0
|
||||||
|
},
|
||||||
|
maneuver: {
|
||||||
|
type: "control-script",
|
||||||
|
parameters: {
|
||||||
|
timeline: [
|
||||||
|
{ t: 0, elevator: 0, throttle: 0.8 },
|
||||||
|
{ t: 1, elevator: 1, throttle: 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(controlScript.samples.length, 11);
|
||||||
|
assert.equal(controlScript.samples.at(-1).controlInputs.elevator, 1);
|
||||||
|
assert.equal(controlScript.samples.at(-1).controlInputs.throttle, 1);
|
||||||
|
|
||||||
|
const cobra = simulateManeuver({
|
||||||
|
aircraftId: "f16-mock",
|
||||||
|
sampleRateHz: 10,
|
||||||
|
durationSec: 3,
|
||||||
|
initialState: {
|
||||||
|
position: [0, 1000, 0],
|
||||||
|
velocityMps: 170,
|
||||||
|
headingDeg: 0
|
||||||
|
},
|
||||||
|
maneuver: {
|
||||||
|
type: "cobra",
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(Math.max(...cobra.samples.map((sample) => sample.alphaDeg)) > 60);
|
||||||
|
|
||||||
|
console.log("simulator tests passed");
|
||||||
Reference in New Issue
Block a user