refactor: 重构架构并整理仓库

This commit is contained in:
Horldsence
2026-09-23 10:56:57 +08:00
parent 680adae339
commit f8aeafdfc2
101 changed files with 14417 additions and 11399 deletions
+20 -49
View File
@@ -10,7 +10,6 @@ env:
jobs: jobs:
lint-and-test: lint-and-test:
name: fmt / clippy / test / doc
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -18,58 +17,47 @@ jobs:
with: with:
components: rustfmt, clippy components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: rustfmt - run: cargo fmt --all -- --check
run: cargo fmt --all -- --check - run: cargo clippy --workspace --all-targets -- -D warnings
- name: clippy - run: cargo test --workspace --all-targets
run: cargo clippy --workspace --all-targets -- -D warnings - name: enforce flat crate source
- name: test run: test -z "$(find crates/*/src -mindepth 2 -type d -print)"
run: cargo test --workspace - name: forbid unsafe and no_std markers
- name: doc run: "! rg '\\bunsafe\\b|libc|no_std' crates Cargo.toml -g '*.rs' -g '*.toml'"
env: - name: optional plot feature
RUSTDOCFLAGS: -D warnings
run: cargo doc --workspace --no-deps
# 整定工具的可选 feature 不在默认 clippy/test 里,单独编译一次防止腐烂。
- name: chassis-tune plot feature
run: cargo check -p chassis-tune --features plot --all-targets run: cargo check -p chassis-tune --features plot --all-targets
# 自动整定的确定性冒烟:同 seed 必须逐位一致,且最优解必须通过验收阈值。 - name: deterministic tuning smoke
- name: tune smoke (deterministic, no hardware)
run: | run: |
cargo run --release -p chassis-tune -- sweep \ cargo run --release -p chassis-tune -- sweep --samples 64 --top-k 3 --max-iters 40 --ensemble 2 --seed 7 --out-dir target/tune-smoke
--samples 64 --top-k 3 --max-iters 40 --ensemble 2 --seed 7 \ cargo run --release -p chassis-tune -- sweep --samples 64 --top-k 3 --max-iters 40 --ensemble 2 --seed 7 --out-dir target/tune-smoke-again
--out-dir target/tune-smoke
cargo run --release -p chassis-tune -- sweep \
--samples 64 --top-k 3 --max-iters 40 --ensemble 2 --seed 7 \
--out-dir target/tune-smoke-again
diff target/tune-smoke/trials.csv target/tune-smoke-again/trials.csv diff target/tune-smoke/trials.csv target/tune-smoke-again/trials.csv
vcan-integration: vcan-integration:
name: vcan0 integration (real kernel CAN stack)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: set up vcan0 - run: |
run: |
sudo modprobe vcan sudo modprobe vcan
sudo ip link add dev vcan0 type vcan sudo ip link add dev vcan0 type vcan
sudo ip link set up vcan0 sudo ip link set up vcan0
- name: run vcan tests - run: cargo test -p socketcan@0.1.0 --test vcan -- --ignored --test-threads 1
run: cargo test -p dji-esc-socketcan -- --ignored --test-threads 1
cross-aarch64: cross-check:
name: cross check aarch64
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
target: [aarch64-unknown-linux-gnu, armv7-unknown-linux-musleabihf]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
targets: aarch64-unknown-linux-gnu targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- run: cargo check --workspace --target aarch64-unknown-linux-gnu - run: cargo check --workspace --target ${{ matrix.target }}
build-armv7: build-armv7:
name: build armv7 static (board target)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -77,21 +65,4 @@ jobs:
with: with:
targets: armv7-unknown-linux-musleabihf targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- run: cargo build --release -p chassisd --target armv7-unknown-linux-musleabihf - run: cargo build --release -p chassisd -p spin -p chassis-tune --target armv7-unknown-linux-musleabihf
# 台架工具与整定工具同样要在板端跑(真机后端常直接跑在 T153 上)。
- run: cargo build --release -p chassis-bench --bin chassis-spin --target armv7-unknown-linux-musleabihf
- run: cargo build --release -p chassis-tune --target armv7-unknown-linux-musleabihf
coverage:
name: coverage (llvm-cov)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@cargo-llvm-cov
- run: cargo llvm-cov --workspace --lcov --output-path lcov.info
- uses: actions/upload-artifact@v4
with:
name: coverage-lcov
path: lcov.info
+115 -1130
View File
File diff suppressed because it is too large Load Diff
Generated
+215 -79
View File
@@ -113,6 +113,23 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bench"
version = "0.1.0"
dependencies = [
"anyhow",
"can",
"esc-bus",
"experiment",
"motor",
"motor-control",
"slcan 0.1.0",
"socketcan 0.1.0",
"thiserror",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -153,7 +170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "can-link" name = "can"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"tokio", "tokio",
@@ -181,30 +198,15 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chassis-bench"
version = "0.1.0"
dependencies = [
"can-link",
"clap",
"dji-esc",
"dji-esc-sim",
"dji-esc-slcan",
"dji-esc-socketcan",
"motor-core",
"thiserror",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "chassis-config" name = "chassis-config"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"dji-esc", "approx",
"esc-bus",
"mecanum", "mecanum",
"motor-core", "motor-control",
"motor-protocol",
"serde", "serde",
"thiserror", "thiserror",
"toml", "toml",
@@ -218,25 +220,32 @@ dependencies = [
"approx", "approx",
"argmin", "argmin",
"argmin-math", "argmin-math",
"can-link", "bench",
"chassis-bench", "can",
"chassis-config", "chassis-config",
"clap", "clap",
"csv", "csv",
"dji-esc", "esc-bus",
"dji-esc-sim", "experiment",
"indicatif", "indicatif",
"motor-core", "motor",
"motor-control",
"motor-model",
"motor-protocol",
"plotters", "plotters",
"rand", "rand",
"rayon", "rayon",
"serde", "serde",
"serde_json", "serde_json",
"sim-harness",
"slcan 0.1.0",
"socketcan 0.1.0",
"thiserror", "thiserror",
"tokio", "tokio",
"toml", "toml",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tuning",
] ]
[[package]] [[package]]
@@ -244,15 +253,15 @@ name = "chassisd"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"approx", "approx",
"can",
"chassis-config", "chassis-config",
"clap", "clap",
"dji-esc", "esc-bus",
"dji-esc-sim",
"dji-esc-socketcan",
"mecanum", "mecanum",
"motor-core", "motor",
"proptest", "motor-control",
"thiserror", "sim-harness",
"socketcan 0.1.0",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -449,6 +458,27 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "delta10"
version = "0.1.0"
dependencies = [
"can",
"delta10-protocol",
"imu",
"thiserror",
"tokio",
]
[[package]]
name = "delta10-protocol"
version = "0.1.0"
dependencies = [
"approx",
"can",
"imu",
"thiserror",
]
[[package]] [[package]]
name = "derive_builder" name = "derive_builder"
version = "0.20.2" version = "0.20.2"
@@ -501,53 +531,6 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "dji-esc"
version = "0.1.0"
dependencies = [
"can-link",
"motor-core",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "dji-esc-sim"
version = "0.1.0"
dependencies = [
"can-link",
"dji-esc",
"motor-core",
"rand",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "dji-esc-slcan"
version = "0.1.0"
dependencies = [
"can-link",
"serial",
"slcan",
"tokio",
"tracing",
]
[[package]]
name = "dji-esc-socketcan"
version = "0.1.0"
dependencies = [
"can-link",
"dji-esc",
"motor-core",
"socketcan",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "dlib" name = "dlib"
version = "0.5.3" version = "0.5.3"
@@ -606,6 +589,30 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "esc-bus"
version = "0.1.0"
dependencies = [
"approx",
"can",
"experiment",
"motor",
"motor-control",
"motor-model",
"motor-protocol",
"sim-harness",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "experiment"
version = "0.1.0"
dependencies = [
"approx",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.5.0" version = "2.5.0"
@@ -839,6 +846,10 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "imu"
version = "0.1.0"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.2" version = "2.14.2"
@@ -990,8 +1001,41 @@ dependencies = [
] ]
[[package]] [[package]]
name = "motor-core" name = "motor"
version = "0.1.0" version = "0.1.0"
dependencies = [
"approx",
"can",
"motor-protocol",
"thiserror",
]
[[package]]
name = "motor-control"
version = "0.1.0"
dependencies = [
"approx",
"motor",
"pid",
]
[[package]]
name = "motor-model"
version = "0.1.0"
dependencies = [
"approx",
"motor-protocol",
]
[[package]]
name = "motor-protocol"
version = "0.1.0"
dependencies = [
"approx",
"can",
"proptest",
"thiserror",
]
[[package]] [[package]]
name = "nb" name = "nb"
@@ -1143,6 +1187,13 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "pid"
version = "0.1.0"
dependencies = [
"approx",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -1546,12 +1597,41 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "sim-harness"
version = "0.1.0"
dependencies = [
"approx",
"can",
"experiment",
"motor",
"motor-control",
"motor-model",
"motor-protocol",
"rand",
"thiserror",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slcan"
version = "0.1.0"
dependencies = [
"can",
"serial",
"slcan 0.1.3",
"thiserror",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "slcan" name = "slcan"
version = "0.1.3" version = "0.1.3"
@@ -1577,6 +1657,21 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "socketcan"
version = "0.1.0"
dependencies = [
"can",
"esc-bus",
"motor",
"motor-control",
"motor-protocol",
"socketcan 4.0.0",
"thiserror",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "socketcan" name = "socketcan"
version = "4.0.0" version = "4.0.0"
@@ -1598,6 +1693,24 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "spin"
version = "0.1.0"
dependencies = [
"bench",
"clap",
"esc-bus",
"experiment",
"mecanum",
"motor",
"motor-control",
"slcan 0.1.0",
"socketcan 0.1.0",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@@ -1809,6 +1922,29 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4"
[[package]]
name = "tuning"
version = "0.1.0"
dependencies = [
"approx",
"argmin",
"argmin-math",
"experiment",
"indicatif",
"motor-control",
"motor-model",
"motor-protocol",
"pid",
"proptest",
"rand",
"rayon",
"serde",
"sim-harness",
"thiserror",
"toml",
"tracing",
]
[[package]] [[package]]
name = "unarray" name = "unarray"
version = "0.1.4" version = "0.1.4"
+45 -20
View File
@@ -1,17 +1,27 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = [ members = [
"crates/motor-core", "crates/can",
"crates/can-link", "crates/imu",
"crates/dji-esc", "crates/motor-protocol",
"crates/dji-esc-sim", "crates/delta10-protocol",
"crates/dji-esc-socketcan", "crates/socketcan",
"crates/dji-esc-slcan", "crates/slcan",
"crates/delta10",
"crates/motor",
"crates/esc-bus",
"crates/pid",
"crates/motor-control",
"crates/mecanum", "crates/mecanum",
"crates/motor-model",
"crates/experiment",
"crates/sim-harness",
"crates/tuning",
"crates/chassis-config", "crates/chassis-config",
"crates/chassis-bench", "crates/bench",
"crates/chassis-tune",
"crates/chassisd", "crates/chassisd",
"crates/spin",
"crates/chassis-tune",
] ]
[workspace.package] [workspace.package]
@@ -27,20 +37,23 @@ strip = true
[workspace.dependencies] [workspace.dependencies]
# 异步运行时 # 异步运行时
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
# CAN 总线(仅 Linux,由 dji-esc-socketcan 按 target 引入) # CAN 总线(仅 Linux,由 socketcan 按 target 引入)
socketcan = { version = "4", features = ["tokio"] } socketcan-rs = { package = "socketcan", version = "4", features = ["tokio"] }
# SLCAN 串口适配器(CANable 等,用户态 Lawicel 协议)
slcan-proto = { package = "slcan", version = "0.1.3" }
serial = "0.4"
# 错误定义与结构化日志 # 错误定义与结构化日志
thiserror = "2" thiserror = "2"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# 配置与命令行(chassisd) # 配置与命令行
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
toml = "0.9" toml = "0.9"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
# 测试 # 测试
proptest = "1" proptest = "1"
approx = "0.5" approx = "0.5"
# 自动整定工具(chassis-tune) # 自动整定工具
# #
# `rand` 对齐 argmin 依赖的 0.9,避免依赖树里出现两个 rand 大版本; # `rand` 对齐 argmin 依赖的 0.9,避免依赖树里出现两个 rand 大版本;
# `argmin-math` 必须显式开启 `vec`(argmin 自身只启 `primitives`), # `argmin-math` 必须显式开启 `vec`(argmin 自身只启 `primitives`),
@@ -56,16 +69,28 @@ anyhow = "1"
# 仅在 chassis-tune 的 `plot` feature 下引入:只要 SVG 后端与折线系列, # 仅在 chassis-tune 的 `plot` feature 下引入:只要 SVG 后端与折线系列,
# 但文字标签需要 `ttf`(字体加载),因此不能用纯 svg_backend 最小集。 # 但文字标签需要 `ttf`(字体加载),因此不能用纯 svg_backend 最小集。
plotters = { version = "0.3", default-features = false, features = ["svg_backend", "line_series", "ttf"] } plotters = { version = "0.3", default-features = false, features = ["svg_backend", "line_series", "ttf"] }
# workspace 内部 crate
motor-core = { path = "crates/motor-core" } # ---- 职责型内部 crate ----
can-link = { path = "crates/can-link" } can = { path = "crates/can" }
dji-esc = { path = "crates/dji-esc" } imu = { path = "crates/imu" }
dji-esc-sim = { path = "crates/dji-esc-sim" } motor-protocol = { path = "crates/motor-protocol" }
dji-esc-socketcan = { path = "crates/dji-esc-socketcan" } delta10-protocol = { path = "crates/delta10-protocol" }
dji-esc-slcan = { path = "crates/dji-esc-slcan" } socketcan = { path = "crates/socketcan" }
slcan = { path = "crates/slcan" }
delta10 = { path = "crates/delta10" }
motor = { path = "crates/motor" }
esc-bus = { path = "crates/esc-bus" }
pid = { path = "crates/pid" }
motor-control = { path = "crates/motor-control" }
mecanum = { path = "crates/mecanum" } mecanum = { path = "crates/mecanum" }
motor-model = { path = "crates/motor-model" }
experiment = { path = "crates/experiment" }
sim-harness = { path = "crates/sim-harness" }
tuning = { path = "crates/tuning" }
chassis-config = { path = "crates/chassis-config" } chassis-config = { path = "crates/chassis-config" }
chassis-bench = { path = "crates/chassis-bench" } bench = { path = "crates/bench" }
chassisd = { path = "crates/chassisd" }
spin = { path = "crates/spin" }
chassis-tune = { path = "crates/chassis-tune" } chassis-tune = { path = "crates/chassis-tune" }
[workspace.lints.rust] [workspace.lints.rust]
+37 -147
View File
@@ -1,171 +1,61 @@
# sentry-chassis # sentry-chassis
机甲大师哨兵机器人麦轮底盘固件:Linux ARM 主控 + 4 × GM3508(C620 电调),CAN 接入(SocketCAN 或串口 slcan),tokio 异步架构。 RoboMaster 哨兵机器人麦轮底盘:Linux ARM 主控、4 × GM3508/C620、SocketCAN 或 SLCAN、tokio 异步运行时。
## 架构一览 架构规范见 [`ARCHITECTURE.md`](ARCHITECTURE.md)。workspace 由 21 个职责单一 crate 组成,不使用 `rm-` 前缀,crate 内源码保持扁平。
| crate | 职责 | 平台 | ## 结构
| --- | --- | --- |
| [`motor-core`](crates/motor-core) | 电机抽象:能力 trait、`MotorState`、PID / 速度环 / 位置环 | 纯逻辑,全平台 |
| [`can-link`](crates/can-link) | CAN 传输抽象:`CanFrame` + `CanLink` 通道对(无协议知识) | 纯逻辑,全平台 |
| [`dji-esc`](crates/dji-esc) | C620/C610 CAN 协议:帧编解码、多圈累计、`EscBus` 异步驱动 | 纯逻辑,全平台 |
| [`dji-esc-sim`](crates/dji-esc-sim) | 被控对象仿真:摩擦 / 延迟 / 负载模型、场景与**离线评估 harness**、异步假电调 | 纯逻辑,全平台 |
| [`dji-esc-socketcan`](crates/dji-esc-socketcan) | SocketCAN 后端:真实 CAN 接口 ↔ 内存通道桥接 | Linux |
| [`dji-esc-slcan`](crates/dji-esc-slcan) | slcan 串口后端:CANable 等 USB 适配器 ↔ 内存通道桥接 | 全平台 |
| [`mecanum`](crates/mecanum) | 麦轮正 / 逆运动学(纯数学) | 全平台 |
| [`chassis-config`](crates/chassis-config) | `chassis.toml` schema:解析、校验、写回(守护进程与整定工具共用) | 全平台 |
| [`chassis-bench`](crates/chassis-bench) | 台架:激励场景、安全包络、真机试验采样 + `chassis-spin` / `socketcan-spin` | 全平台(SocketCAN 工具需 Linux) |
| [`chassis-tune`](crates/chassis-tune) | **速度环 PID 自动整定**:参数空间搜索 + 指标 / 成本 + 报告(仿真 / 真机)+ 版本化**超参数预设** | 全平台 |
| [`chassisd`](crates/chassisd) | 底盘守护进程:配置、1kHz 控制循环、安全逻辑、`--mock` 模式 | 全平台(实车需 Linux) |
分层原则:`motor-core`、`dji-esc`、`dji-esc-sim` 不做任何真实 I/O——CAN 端口被抽象为一对 tokio 通道 - `can` / `imu`:基础接口。
(`CanLink`),因此协议、运动学、控制与整定逻辑可以在 macOS / CI 上完整测试;真实总线只出现在最外层 - `motor-protocol` / `delta10-protocol`:纯协议编解码。
(`dji-esc-socketcan` / `dji-esc-slcan`,以及组合它们的 `chassis-bench` / `chassisd`)。 - `socketcan` / `slcan` / `delta10`:硬件驱动。
- `motor` / `esc-bus`:设备契约与安全总线运行时。
- `pid` / `motor-control` / `mecanum`:纯控制算法。
- `motor-model` / `experiment` / `sim-harness` / `tuning`:仿真与自动整定。
- `chassis-config` / `bench` / `chassisd` / `spin` / `chassis-tune`:配置、台架和应用入口。
> 运输后端 crate(`dji-esc-slcan` / `dji-esc-socketcan`)只做桥接,**不含应用**; `esc-bus` 不依赖控制算法。应用把 `motor-control::MotorPolicy` 作为
> 台架程序住在 `chassis-bench`,自动整定住在 `chassis-tune`。 `motor::EffortPolicy` 传入总线,从而保持依赖无环。
> **命令是租约**:`EscMotor::set_velocity` / `set_effort_raw` / `set_position` 每次调用即续租,
> 超过 `BusOptions::command_timeout`(默认 50ms)未刷新,驱动强制 0 电流。
> 控制循环必须**逐拍重发**同一个设定值(`chassisd` 的 1kHz 循环天然满足)。
## 开发 ## 开发
需要 Rust stable(仓库通过 `rust-toolchain.toml` 锁定)。
```sh ```sh
cargo test --workspace # 单测 + 闭环仿真测试(无需硬件) cargo test --workspace --all-targets
cargo run -p chassisd -- --mock # 无硬件全链路演示 cargo run -p chassisd -- --mock
just tune-smoke # 自动整定冒烟(确定性,无需硬件,CI 同款) cargo run -p spin -- chassis --device /dev/ttyACM0 --dry-run
just lint # fmt + clippy(与 CI 同标准) cargo run -p chassis-tune -- presets --preset config/tune.toml
just lint
just tune-smoke
``` ```
### Linux 虚拟 CAN(vcan0)集成测试 底盘控制频率为 1 kHz。命令是 50 ms 租约,上层必须逐拍重发目标值。
## 台架
```sh ```sh
just vcan-test # 或见 justfile 内的 modprobe / ip link 命令 # 四轮 500 rpm 转子,slcan
cargo run --release -p spin -- chassis --device /dev/ttyACM0 --rpm 500
# 单电机 SocketCAN 验证
cargo run --release -p spin -- motor can0 1 1000 10
``` ```
## 台架:四轮 500 rpm 旋转测试(slcan) 轮子必须架空或整车固定,急停可及。工具默认限流、限时并支持 ctrl-c 安全停机。
实车台架验证工具 `chassis-spin`:四轮同时以固定**转子**转速旋转(默认 500 rpm, ## 自动整定
轮端 = 转子 ÷ 减速比 ≈ 26 rpm),带缓升、过速保护、上电门控与安全停机。
```sh ```sh
# 本机直接跑(CANable2 在 /dev/ttyACM0)
cargo run --release -p chassis-bench --bin chassis-spin -- --device /dev/ttyACM0
# 只验证链路与反馈,不发非零电流(接线排查首选)
cargo run --release -p chassis-bench --bin chassis-spin -- --device /dev/ttyACM0 --dry-run
# 目标板(T153,armv7):交叉编译 → 推送 → 运行
just bench-spin # 默认 root@192.168.2.2 + /dev/ttyACM0 + 500 rpm
just bench-check # 同上,但只做 dry-run
```
安全与使用要点:
- **轮子必须架空或整车固定**;ctrl-c 随时安全停机(先 0 电流再关总线)。
- 上电门控:四个电调都上报新鲜反馈前绝不下发速度;只接了部分电调时用 `--allow-partial` 降级。
- 过速保护:任一电调超过 `--max-rpm`(默认 目标 × 2 + 200)持续 60ms 即立即停机。
- 空载台架的合适整定与带载完全不同:默认 `kp=2 ki=10 ff=0 --current-limit 1500`,
空载**不要**加前馈(会直接把轮子推到最高速度)。
## 自动整定(chassis-tune)
手动试凑 `kp/ki/kd/ff` 既慢又难复现。`chassis-tune` 把这件事变成**参数空间搜索**:
种子化分层粗扫(`rayon` 并行)→ 以 top-k 为起点做多起点 `argmin` 局部精修(Nelder-Mead / PSO),
每次评估都用同一套指标(上升时间、超调、调节时间、稳态误差、IAE、电流)和同一套硬约束
(过速 / 长时间饱和 / 不收敛直接判罚,不参与权重折衷)。
```sh
# 0) 看有哪些超参数预设(只读;预设文件见 config/tune.toml)
cargo run -p chassis-tune -- presets
# 1) 仿真搜索(默认,不碰硬件;同 seed 结果逐位一致)
cargo run --release -p chassis-tune -- sweep --samples 512 --out-dir target/tune
# 1b) 用预设跑:超参数(区间 / 场景 / 权重 / 阈值 / 预算 / 安全包络)进版本管理
# 优先级:内置默认 < 预设文件 < 显式 CLI(逐字段覆盖)
cargo run --release -p chassis-tune -- sweep \ cargo run --release -p chassis-tune -- sweep \
--preset config/tune.toml --preset-name bench_loaded --preset config/tune.toml --preset-name sim_bench_model \
--samples 512 --out-dir target/tune
# 2) 只复算一组给定增益(CI / 回归用,按验收阈值给退出码:0 通过 / 1 未通过 / 2 参数错)
cargo run -p chassis-tune -- validate --gains 2,10,0,0
# 3) 台架复验(**轮子会转**:必须架空或整车固定、急停可及)
cargo run --release -p chassis-tune -- sweep \
--preset config/tune.toml --preset-name bench_noload \
--device /dev/ttyACM0 --esc-id 1 --i-know-the-wheel-spins
``` ```
随仓库提供的预设(`config/tune.toml`): 仿真搜索不接触硬件。真机后端会转动轮子,必须显式传入
`--i-know-the-wheel-spins`。详细接线、标定和排障见
[`docs/chassis.md`](docs/chassis.md)。
| 预设 | 后端 | 用途 | ## 实时调度
| --- | --- | --- |
| `sim_bench_model`(默认) | 仿真 | 台架量级模型全量搜索:评审 / CI / 代码回归基准 |
| `sim_ideal` | 仿真 | 理想模型秒级回归(结果**不可直接上车**) |
| `bench_noload` | 真机 | 空载台架,小样本局部搜索,前馈固定 0 |
| `bench_loaded` | 真机 | 带载(整车落地),前馈区间上调 |
| `field_updown` | 真机 | 实战验收:升 / 降 / 回零双向,阈值收紧 |
预设**不能**携带 `current_limit` / `direction` / `gear_ratio`(写了直接报错)—— 仓库不使用 unsafe 或 libc FFI。生产调度策略由
安全与机械约束只来自 `chassis.toml` 或显式 CLI。 [`deploy/chassisd.service`](deploy/chassisd.service) 的 systemd 指令设置。
std 程序不调用 `mlockall`。
产物(`--out-dir`):`trials.csv`(全部候选与判定)、`best.toml`(可粘贴的增益片段)、
`best-trace.csv`、`report.md`、`summary.json`;台架模式另有 `hil-validation.csv`(同一组增益的重复性复验)。
加 `--features plot` 还会输出 `best.svg` 三联图。
整定工具与守护进程**共用同一份配置 schema**(`chassis-config`),因此可以直接拿实车配置当输入:
```sh
# 用 chassis.toml 里的限流 / 电调 ID / 方向 / 减速比(这些**不参与搜索**),
# 打印与现有增益的成本对比,并把结果写回一份新配置
cargo run --release -p chassis-tune -- sweep \
--config config/chassis.toml.example --motor front_left \
--write-config target/chassis.tuned.toml
```
要点:
- **仿真整定只保证在被控对象模型内最优**。模型里的摩擦 / 延迟是量级估计,且不含测量噪声,
因此仿真最优解可能偏激进;交付前必须用同一 `--scenario` 跑一次 `--backend bench` 复验。
- 最优解贴到参数空间边界时报告会明确提示——那是"约束在决定结果",应放宽 `--kp` / `--ki` 再搜。
- 台架搜索串行执行(只有一条总线),每次试验后强制 0 电流并静默 `--between-trials-ms`;
过速 / 过温 / 掉线 / 超时 / ctrl-c 任一命中即停机并收工。
- `current_limit`、`direction`、`gear_ratio` 属于安全与机械约束,**不参与搜索**。
- 换一组工况先加**预设**(`config/tune.toml`)而不是复制一长串命令行:
`chassis-tune presets` 查看、`--preset-name` 选用、CLI 只覆盖本次要变的项。
## 部署到目标板(armv7,实测:创龙 T153 / Ubuntu 22.04)
二进制为 musl 静态链接,不依赖板端 libc,无需交叉工具链:
```sh
just deploy # 编译 + 推送 + mock 试运行(默认 root@192.168.2.2)
# 或手动:
cargo build --release -p chassisd --target armv7-unknown-linux-musleabihf
scp target/armv7-unknown-linux-musleabihf/release/chassisd root@192.168.2.2:/tmp/
```
板端 CAN 有两条互斥路径:
- **SocketCAN**(板载原生 CAN 控制器,走 `can0`/`can1`):
`ip link set can0 up type can bitrate 1000000 restart-ms 100`,配 `--interface can0`。
- **slcan**(USB 适配器,如 CANable2 在 CDC ACM 下枚举为 `/dev/ttyACM0`):
用 [`dji-esc-slcan`](crates/dji-esc-slcan)(`chassisd` / [`chassis-bench`](crates/chassis-bench) 均可),无需 `ip link`。
实车运行:`/tmp/chassisd --config /tmp/chassis.toml --interface can0`。板上运行样例见
[`deploy/chassisd.service`](deploy/chassisd.service),配置样例见
[`config/chassis.toml.example`](config/chassis.toml.example)。
> 注意:电调未上电 / CAN 线未接时,SocketCAN 侧会因无 ACK 进入 bus-off(可用
> `ip -d link show can0` 查看),chassisd 侧表现为四电机反馈离线、强制零电流——
> 这正是安全设计的预期行为。
## 文档
- [**底盘开发文档**](docs/chassis.md)——硬件接线、坐标与单位约定、控制与安全设计、配置参考、
**标定与上车流程**、自动整定与超参数预设、台架工具、故障排查(开发 / 调试 / 上车先看这份)
- [架构设计](docs/architecture.md)——分层、CAN 协议要点、单位与方向约定、被控对象模型、参数空间搜索、测试策略
- [整体架构规划](ARCHITECTURE.md)——全系统(含云台 / 发射 / IMU / 实时性)的 crate 划分与阶段计划
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "bench"
description = "Hardware bench links, safety envelope, and trial runner"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
can = { workspace = true }
motor = { workspace = true }
esc-bus = { workspace = true }
experiment = { workspace = true }
motor-control = { workspace = true }
socketcan = { workspace = true }
slcan = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
anyhow = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
+947
View File
@@ -0,0 +1,947 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub mod link {
//! 链路打开:把两种运输后端收敛成一个门面。
//!
//! 台架工具与自动整定的真机后端都需要"给个设备名,拿回一条 [`CanLink`]",
//! 而不关心底层是 slcan 串口还是 SocketCAN。把选择留在这一处,上层就不必
//! 同时依赖两个运输 crate。
use std::io::Result;
use std::time::Duration;
use can::CanLink;
/// slcan 波特率档位(对应协议 `Sn` 命令)。
pub use slcan::CanSpeed;
/// slcan 打开参数。
pub use slcan::SlcanOptions;
/// 打开 slcan 串口设备(CANable 等),桥接为链路端点。
///
/// # Errors
///
/// 设备不存在、权限不足或波特率不被支持时的 `io::Error`。
pub fn open_slcan(device: &str, options: SlcanOptions) -> Result<CanLink> {
slcan::open_link_with(device, options)
}
/// 打开 SocketCAN 接口(Linux)。
///
/// # Errors
///
/// 接口不存在 / 未 up / 权限不足时的 `io::Error`;非 Linux 平台恒为错误。
#[cfg(target_os = "linux")]
pub fn open_socketcan(interface: &str) -> Result<CanLink> {
socketcan::open_link(interface)
}
/// 非 Linux 平台没有 SocketCAN。
///
/// # Errors
///
/// 恒返回 `Unsupported`。
#[cfg(not(target_os = "linux"))]
pub fn open_socketcan(interface: &str) -> Result<CanLink> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!("SocketCAN ({interface}) 仅支持 Linux"),
))
}
/// 默认 slcan 参数(1 Mbps、串口超时 50 ms)。
#[must_use]
pub fn default_slcan_options(serial_baud: u32, speed: CanSpeed) -> SlcanOptions {
SlcanOptions { serial_baud, speed, read_timeout: Duration::from_millis(50) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn socketcan_is_unavailable_off_linux() {
#[cfg(not(target_os = "linux"))]
{
let err = open_socketcan("can0").expect_err("非 Linux 必须报错");
assert_eq!(err.kind(), std::io::ErrorKind::Unsupported);
}
#[cfg(target_os = "linux")]
{
// Linux 上接口名不存在时也必须是 Err,而不是 panic。
let _ = open_socketcan("definitely-not-a-can-interface");
}
}
#[test]
fn slcan_failure_is_an_error_not_a_panic() {
let result = open_slcan(
"/definitely/not/a/device",
default_slcan_options(2_000_000, CanSpeed::M1),
);
assert!(result.is_err());
}
#[test]
fn default_options_carry_the_requested_baud() {
let options = default_slcan_options(115_200, CanSpeed::K500);
assert_eq!(options.serial_baud, 115_200);
assert_eq!(options.speed, CanSpeed::K500);
assert_eq!(options.read_timeout, Duration::from_millis(50));
}
}
}
pub mod safety {
//! 台架安全包络:把"什么时候必须立刻停机"变成可复用的判定。
//!
//! 台架工具(`chassis-spin`)与自动整定的真机后端(`chassis-tune --backend bench`)
//! 共用同一套判定,避免安全逻辑在两个地方各写一遍、然后慢慢漂移。
//!
//! 所有阈值都是 `Option`:只开需要的那个。`chassis-spin` 只开过速保护
//! (保持历史行为),自动整定则开启全部——因为搜索会主动试错,必须由
//! 包络兜住"试错到失控"。
use std::time::Duration;
use motor::MotorFeedback;
// 用 tokio 的时钟:`start_paused` 下虚拟时间可控,安全包络(宽限期 / 超时)
// 才能被确定性地测试;生产构建里它就是 `std::time::Instant`。
use thiserror::Error;
use tokio::time::Instant;
/// 触发停机的原因。
#[derive(Debug, Clone, PartialEq, Error)]
pub enum Abort {
/// 转速超过上限并持续超过宽限期。
#[error("overspeed: {peak_rpm:.0} rpm > {limit_rpm:.0} rpm for over {grace_ms}ms")]
Overspeed {
/// 观测到的峰值转速(rpm)。
peak_rpm: f32,
/// 配置的上限(rpm)。
limit_rpm: f32,
/// 宽限期(毫秒)。
grace_ms: u64,
},
/// 温度超过上限。
#[error("overtemperature: {temperature_c}℃ > {limit_c}℃")]
OverTemperature {
/// 观测温度(℃)。
temperature_c: i8,
/// 上限(℃)。
limit_c: i8,
},
/// 反馈超出新鲜度阈值(掉线)。
#[error("esc {esc_id} feedback lost: age {age_ms:?}ms > {timeout_ms}ms")]
FeedbackLost {
/// 电调 ID。
esc_id: u8,
/// 实测反馈年龄(毫秒)。
age_ms: Option<u128>,
/// 阈值(毫秒)。
timeout_ms: u64,
},
/// 整个试验超时。
#[error("trial timeout after {elapsed_ms}ms")]
Timeout {
/// 已耗时(毫秒)。
elapsed_ms: u128,
},
/// 上层主动取消(ctrl-c)。
#[error("cancelled by operator")]
Cancelled,
}
/// 安全包络(阈值 + 宽限期)。
#[derive(Debug, Clone, PartialEq)]
pub struct SafetyEnvelope {
/// 转速上限(rpm)。
pub max_rpm: Option<f32>,
/// 温度上限(℃)。
pub max_temperature_c: Option<i8>,
/// 反馈新鲜度阈值:超过它即视为掉线。
pub feedback_timeout: Option<Duration>,
/// 过速需要持续多久才判定为失控(抑制单帧噪声)。
pub overspeed_grace: Duration,
/// 单次试验的总时长上限。
pub trial_timeout: Option<Duration>,
}
impl Default for SafetyEnvelope {
fn default() -> Self {
Self {
max_rpm: None,
max_temperature_c: None,
feedback_timeout: None,
overspeed_grace: Duration::from_millis(60),
trial_timeout: None,
}
}
}
impl SafetyEnvelope {
/// 空包络(不判定任何条件)。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 只开过速保护(`chassis-spin` 的历史行为)。
#[must_use]
pub fn overspeed_only(max_rpm: f32, grace: Duration) -> Self {
Self { max_rpm: Some(max_rpm), overspeed_grace: grace, ..Self::default() }
}
/// 开过速保护。
#[must_use]
pub fn with_overspeed(mut self, max_rpm: f32, grace: Duration) -> Self {
self.max_rpm = Some(max_rpm);
self.overspeed_grace = grace;
self
}
/// 开温度保护。
#[must_use]
pub fn with_temperature_max(mut self, max_temperature_c: i8) -> Self {
self.max_temperature_c = Some(max_temperature_c);
self
}
/// 开掉线保护。
#[must_use]
pub fn with_feedback_timeout(mut self, timeout: Duration) -> Self {
self.feedback_timeout = Some(timeout);
self
}
/// 开试验总时长保护。
#[must_use]
pub fn with_trial_timeout(mut self, timeout: Duration) -> Self {
self.trial_timeout = Some(timeout);
self
}
/// 是否需要任何判定(空包络时调用方可以跳过整段检查)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.max_rpm.is_none()
&& self.max_temperature_c.is_none()
&& self.feedback_timeout.is_none()
&& self.trial_timeout.is_none()
}
}
/// 带状态的安全监视器(过速宽限与总时长需要记忆)。
#[derive(Debug, Clone)]
pub struct SafetyMonitor {
envelope: SafetyEnvelope,
started: Instant,
overspeed_since: Option<Instant>,
}
impl SafetyMonitor {
/// 创建监视器并记录起点。
#[must_use]
pub fn new(envelope: SafetyEnvelope, now: Instant) -> Self {
Self { envelope, started: now, overspeed_since: None }
}
/// 当前包络。
#[must_use]
pub const fn envelope(&self) -> &SafetyEnvelope {
&self.envelope
}
/// 检查一组电机在当前时刻是否触碰包络。
///
/// 返回 `Some(Abort)` 即调用方**必须立刻停电流并结束本次试验**。
/// 每次调用**不分配**(1 kHz 台架循环里每拍都要过一遍)。
pub fn check<M: MotorFeedback>(&mut self, motors: &[M], now: Instant) -> Option<Abort> {
if let Some(timeout) = self.envelope.trial_timeout {
let elapsed = now.saturating_duration_since(self.started);
if elapsed > timeout {
return Some(Abort::Timeout { elapsed_ms: elapsed.as_millis() });
}
}
if let Some(timeout) = self.envelope.feedback_timeout {
for motor in motors {
let age = motor.feedback_age();
if age.is_none_or(|age| age > timeout) {
return Some(Abort::FeedbackLost {
esc_id: motor.id(),
age_ms: age.map(|age| age.as_millis()),
timeout_ms: timeout.as_millis() as u64,
});
}
}
}
if let Some(limit) = self.envelope.max_temperature_c {
for motor in motors {
let temperature = motor.state().temperature_c;
if temperature > limit {
return Some(Abort::OverTemperature {
temperature_c: temperature,
limit_c: limit,
});
}
}
}
if let Some(limit) = self.envelope.max_rpm {
let peak = motors
.iter()
.map(|motor| motor.state().rotor_speed_rpm.abs())
.fold(0.0f32, f32::max);
if peak > limit {
match self.overspeed_since {
None => self.overspeed_since = Some(now),
Some(since)
if now.saturating_duration_since(since)
>= self.envelope.overspeed_grace =>
{
return Some(Abort::Overspeed {
peak_rpm: peak,
limit_rpm: limit,
grace_ms: self.envelope.overspeed_grace.as_millis() as u64,
});
}
Some(_) => {}
}
} else {
self.overspeed_since = None;
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use motor::{MockMotor, MotorState};
fn motor_with(id: u8, rpm: f32, temperature_c: i8, age: Option<Duration>) -> MockMotor {
let motor = MockMotor::new(id);
let state = MotorState {
rotor_speed_rpm: rpm,
temperature_c,
feedback_count: 1,
..MotorState::default()
};
match age {
Some(age) => motor.inject_state_with_age(state, age),
None => motor.inject_state(state),
}
motor
}
#[test]
fn empty_envelope_never_aborts() {
let motors = vec![motor_with(1, 1.0e6, 120, Some(Duration::from_secs(5)))];
let mut monitor = SafetyMonitor::new(SafetyEnvelope::new(), Instant::now());
assert!(monitor.envelope().is_empty());
assert_eq!(monitor.check(&motors, Instant::now()), None);
}
#[test]
fn overspeed_needs_the_grace_period() {
let motors = vec![motor_with(1, 5000.0, 30, None)];
let grace = Duration::from_millis(60);
let mut monitor =
SafetyMonitor::new(SafetyEnvelope::overspeed_only(3000.0, grace), Instant::now());
let now = Instant::now();
assert_eq!(monitor.check(&motors, now), None, "宽限期内不得触发");
assert_eq!(monitor.check(&motors, now + Duration::from_millis(30)), None);
let abort = monitor.check(&motors, now + grace);
assert!(matches!(abort, Some(Abort::Overspeed { .. })), "{abort:?}");
}
#[test]
fn overspeed_timer_resets_when_back_under_limit() {
let motors = vec![motor_with(1, 5000.0, 30, None)];
let grace = Duration::from_millis(60);
let mut monitor =
SafetyMonitor::new(SafetyEnvelope::overspeed_only(3000.0, grace), Instant::now());
let now = Instant::now();
assert_eq!(monitor.check(&motors, now), None);
let calm = vec![motor_with(1, 100.0, 30, None)];
assert_eq!(monitor.check(&calm, now + Duration::from_millis(30)), None);
// 重新超速:计时必须从头开始。
assert_eq!(monitor.check(&motors, now + Duration::from_millis(40)), None);
assert!(monitor.check(&motors, now + Duration::from_millis(90)).is_none());
assert!(monitor.check(&motors, now + Duration::from_millis(110)).is_some());
}
#[test]
fn temperature_and_feedback_loss_abort_immediately() {
let envelope = SafetyEnvelope::new()
.with_temperature_max(70)
.with_feedback_timeout(Duration::from_millis(100));
let hot = vec![motor_with(1, 0.0, 80, None)];
let mut monitor = SafetyMonitor::new(envelope.clone(), Instant::now());
assert!(matches!(
monitor.check(&hot, Instant::now()),
Some(Abort::OverTemperature { .. })
));
let stale = vec![motor_with(1, 0.0, 30, Some(Duration::from_millis(250)))];
let mut monitor = SafetyMonitor::new(envelope, Instant::now());
assert!(matches!(
monitor.check(&stale, Instant::now()),
Some(Abort::FeedbackLost { esc_id: 1, .. })
));
}
#[test]
fn motor_without_any_feedback_is_lost() {
let envelope = SafetyEnvelope::new().with_feedback_timeout(Duration::from_millis(100));
// 从未收到过反馈:age = None。
let silent = vec![MockMotor::new(3)];
let mut monitor = SafetyMonitor::new(envelope, Instant::now());
assert!(matches!(
monitor.check(&silent, Instant::now()),
Some(Abort::FeedbackLost { esc_id: 3, age_ms: None, .. })
));
}
#[test]
fn trial_timeout_aborts() {
let envelope = SafetyEnvelope::new().with_trial_timeout(Duration::from_millis(500));
let motors = vec![motor_with(1, 0.0, 30, None)];
let start = Instant::now();
let mut monitor = SafetyMonitor::new(envelope, start);
assert_eq!(monitor.check(&motors, start + Duration::from_millis(100)), None);
let abort = monitor.check(&motors, start + Duration::from_millis(600));
assert!(matches!(abort, Some(Abort::Timeout { .. })), "{abort:?}");
}
}
}
pub mod sampler {
//! 真机试验:把一条 [`Scenario`] 在真实电调上重放并采集成 [`Trace`]。
//!
//! 与 `sim-harness` 的离线 harness 是**同一套场景词汇**:整定工具先用离线
//! harness 搜索,再用本模块在台架上复核,两条路径的指标口径因此一致。
//!
//! # 安全契约
//!
//! - 每个采样拍都重发设定值——**命令是租约**,不重发会在 50ms 后失电;
//! - 每个采样拍都过一遍 [`SafetyMonitor`];
//! - 无论正常结束、触发包络还是被取消,退出前一律 `disable()`(0 电流)。
use std::future::Future;
use std::time::Duration;
use experiment::{Scenario, Trace};
use motor::{Disable, MotorFeedback, VelocityDrive};
use tokio::time::{Instant, MissedTickBehavior, interval};
use crate::safety::{Abort, SafetyEnvelope, SafetyMonitor};
/// 一次真机试验的结果。
#[derive(Debug, Clone)]
pub struct TrialOutcome {
/// 采集到的轨迹(触发中止时只含中止前的采样)。
pub trace: Trace,
/// 触发安全包络 / 被取消的原因;`None` 表示按场景跑完。
pub abort: Option<Abort>,
}
impl TrialOutcome {
/// 是否完整跑完场景(没有被安全包络打断)。
#[must_use]
pub fn completed(&self) -> bool {
self.abort.is_none()
}
}
/// 采样节拍下限(防止 `dt_s` 给出病态值把 CPU 打满)。
const MIN_TICK: Duration = Duration::from_micros(50);
/// 在单台电机上重放一条场景。
///
/// - `effort_limit_raw`:本次试运行的 effort 限幅,仅用于标注 `saturated`
/// (控制器真正的钳位在驱动层按 `EscConfig::current_limit` 完成);
/// - `lease_period`:命令租约的续期节拍(比 `scenario.dt_s` 更稀时以采样节拍为准);
/// - `cancel`:外部取消源(ctrl-c / 搜索预算耗尽)。
pub async fn run_step_trial<M, F>(
motor: &M,
scenario: &Scenario,
envelope: &SafetyEnvelope,
effort_limit_raw: i16,
lease_period: Duration,
cancel: F,
) -> TrialOutcome
where
M: VelocityDrive + MotorFeedback + Disable,
F: Future<Output = ()>,
{
let dt = Duration::from_secs_f32(scenario.dt_s.max(1.0e-6)).max(MIN_TICK);
let total = Duration::from_secs_f32(scenario.total_s().max(0.0));
// 续租节拍不得稀于采样节拍,否则会短暂失电。
let lease_period = lease_period.min(dt);
let started = Instant::now();
let mut monitor = SafetyMonitor::new(envelope.clone(), started);
let mut ticker = interval(dt);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut lease = interval(lease_period);
lease.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut trace = Trace::with_capacity(scenario.samples());
let mut setpoint = scenario.setpoint_at(0.0);
let mut abort = None;
tokio::pin!(cancel);
loop {
tokio::select! {
() = &mut cancel => {
abort = Some(Abort::Cancelled);
break;
}
_ = lease.tick() => {
// 续租:命令是租约,不重发就失电。
motor.set_velocity(setpoint);
}
_ = ticker.tick() => {
let now = Instant::now();
let elapsed = now.saturating_duration_since(started);
if elapsed > total {
break;
}
let t_s = elapsed.as_secs_f32();
setpoint = scenario.setpoint_at(t_s);
motor.set_velocity(setpoint);
let state = motor.state();
let saturated =
i32::from(state.effort_raw).abs() >= i32::from(effort_limit_raw);
trace.push(t_s, setpoint, state.rotor_speed_rpm, state.effort_raw, saturated);
if let Some(reason) = monitor.check(std::slice::from_ref(motor), now) {
abort = Some(reason);
break;
}
}
}
}
// 安全收尾:任何退出路径都先归零,绝不把非零给定留在总线上。
motor.disable();
TrialOutcome { trace, abort }
}
/// 以 ctrl-c 作为取消源的 [`run_step_trial`] 包装。
pub async fn run_step_trial_until_ctrl_c<M>(
motor: &M,
scenario: &Scenario,
envelope: &SafetyEnvelope,
effort_limit_raw: i16,
lease_period: Duration,
) -> TrialOutcome
where
M: VelocityDrive + MotorFeedback + Disable,
{
run_step_trial(motor, scenario, envelope, effort_limit_raw, lease_period, async {
let _ = tokio::signal::ctrl_c().await;
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
use motor::{Command, MockMotor, MotorState};
fn ready_motor(id: u8) -> MockMotor {
let motor = MockMotor::new(id);
motor.inject_state(MotorState { feedback_count: 1, ..MotorState::default() });
motor
}
#[tokio::test(start_paused = true)]
async fn trial_follows_scenario_and_disables_at_the_end() {
let motor = ready_motor(1);
let scenario = Scenario::ramp_and_hold(1000.0, 0.1, 0.2);
let outcome = run_step_trial(
&motor,
&scenario,
&SafetyEnvelope::new(),
12_000,
Duration::from_millis(5),
std::future::pending::<()>(),
)
.await;
assert!(outcome.completed(), "{:?}", outcome.abort);
assert!(outcome.trace.is_consistent());
assert!(outcome.trace.len() > 250, "len = {}", outcome.trace.len());
// 斜坡中点的设定值应约为一半。
let mid = outcome.trace.window(0.049, 0.052).setpoint_rpm()[0];
assert!((mid - 500.0).abs() < 30.0, "mid setpoint = {mid}");
// 收尾必须归零。
assert_eq!(motor.last_command(), Command::Disabled);
}
#[tokio::test(start_paused = true)]
async fn overspeed_aborts_and_zeroes_output() {
let motor = ready_motor(1);
// 反馈恒为 5000 rpm,远超 3000 rpm 上限。
motor.inject_state(MotorState {
rotor_speed_rpm: 5000.0,
feedback_count: 1,
..MotorState::default()
});
let envelope = SafetyEnvelope::overspeed_only(3000.0, Duration::from_millis(20));
let scenario = Scenario::step(1000.0, 2.0);
let outcome = run_step_trial(
&motor,
&scenario,
&envelope,
12_000,
Duration::from_millis(5),
std::future::pending::<()>(),
)
.await;
assert!(matches!(outcome.abort, Some(Abort::Overspeed { .. })), "{:?}", outcome.abort);
assert_eq!(motor.last_command(), Command::Disabled);
// 中止得足够早,不该跑满场景。
assert!(outcome.trace.len() < 200, "len = {}", outcome.trace.len());
}
#[tokio::test(start_paused = true)]
async fn stalled_feedback_aborts() {
let motor = MockMotor::new(4);
motor.inject_state_with_age(
MotorState { feedback_count: 1, ..MotorState::default() },
Duration::from_millis(500),
);
let envelope = SafetyEnvelope::new().with_feedback_timeout(Duration::from_millis(100));
let scenario = Scenario::step(500.0, 1.0);
let outcome = run_step_trial(
&motor,
&scenario,
&envelope,
12_000,
Duration::from_millis(5),
std::future::pending::<()>(),
)
.await;
assert!(
matches!(outcome.abort, Some(Abort::FeedbackLost { esc_id: 4, .. })),
"{:?}",
outcome.abort
);
assert_eq!(motor.last_command(), Command::Disabled);
}
#[tokio::test(start_paused = true)]
async fn cancellation_stops_and_zeroes_output() {
let motor = ready_motor(2);
let scenario = Scenario::step(800.0, 5.0);
let outcome = run_step_trial(
&motor,
&scenario,
&SafetyEnvelope::new(),
12_000,
Duration::from_millis(5),
async { tokio::time::sleep(Duration::from_millis(50)).await },
)
.await;
assert_eq!(outcome.abort, Some(Abort::Cancelled));
assert_eq!(motor.last_command(), Command::Disabled);
assert!(!outcome.trace.is_empty());
assert!(outcome.trace.len() < 200, "取消后不得继续采样");
}
}
}
pub mod hil {
//! 真机(HIL)评估后端:在台架上逐次试验一组增益。
//!
//! 与 [`SimEvaluator`](crate::SimEvaluator) 实现同一个 [`Evaluator`],
//! 因此 `sweep` / `validate` 的全部逻辑(粗扫、精修、报告)都不需要改动,
//! 只是"评估一组增益有多好"这件事从浮点积分换成了真的让轮子转。
//!
//! # 安全设计
//!
//! 每次试验都:① 先确认目标电调反馈新鲜;② 逐拍重发设定值(命令租约);
//! ③ 每拍过一遍 [`SafetyEnvelope`];④ 无论正常结束、触发包络还是 ctrl-c,
//! 退出前一律 `disable()`;⑤ 试验之间插入静默期,让轮子真正停下来再开始下一组。
//!
//! 评估是**串行**的(真机只有一条总线)。调用方应把搜索放进单线程 rayon 池
//! (见 `main`),否则大量线程会在互斥锁上排队、白白浪费时间。
use std::sync::Mutex;
use std::time::Duration;
use crate::{SafetyEnvelope, run_step_trial};
use anyhow::Context as _;
use esc_bus::{BusOptions, EscBus, EscBusHandle, EscConfig, EscMotor};
use experiment::Scenario;
use motor::{Disable as _, MotorFeedback};
use motor_control::{MotorPolicy, VelocityGains};
use thiserror::Error;
use crate::sampler::TrialOutcome;
/// 真机后端错误。
#[derive(Debug, Error)]
pub enum BenchError {
/// 无法打开总线。
#[error("cannot open CAN link: {0}")]
Link(String),
/// 电调总线启动失败。
#[error("cannot start ESC bus: {0}")]
Bus(String),
/// 启动超时:目标电调没有反馈。
#[error(
"esc {esc_id} has no fresh feedback after {waited_ms}ms (wiring / power / bitrate?)"
)]
NoFeedback {
/// 目标电调 ID。
esc_id: u8,
/// 等待时长(毫秒)。
waited_ms: u64,
},
/// 搜索期间因安全事件中止且未要求继续。
#[error("aborted by safety envelope: {reason}")]
Aborted {
/// 触发原因。
reason: String,
},
}
/// 真机评估配置。
#[derive(Debug, Clone)]
pub struct BenchConfig {
/// 电调配置(名称、ID、方向、减速比、限流)——增益由搜索覆写。
pub esc: EscConfig,
/// 总线运行参数。
pub bus: BusOptions,
/// 评估场景。
pub scenario: Scenario,
/// 安全包络。
pub envelope: SafetyEnvelope,
/// 试验之间的静默期(等轮子停稳)。
pub between_trials: Duration,
/// 命令租约续期节拍。
pub lease_period: Duration,
/// 启动时等待反馈的超时。
pub startup_timeout: Duration,
}
impl BenchConfig {
/// 合理默认:1 kHz 总线、50 ms 租约、5 s 启动超时、500 ms 试验间隔。
#[must_use]
pub fn new(esc: EscConfig, scenario: Scenario, envelope: SafetyEnvelope) -> Self {
Self {
esc,
bus: BusOptions::default(),
scenario,
envelope,
between_trials: Duration::from_millis(500),
lease_period: Duration::from_millis(5),
startup_timeout: Duration::from_secs(5),
}
}
}
/// 真机评估器(持有总线与运行时)。
pub struct BenchEvaluator {
session: Mutex<Session>,
scenario: Scenario,
envelope: SafetyEnvelope,
effort_limit_raw: i16,
between_trials: Duration,
lease_period: Duration,
}
struct Session {
runtime: tokio::runtime::Runtime,
bus: Option<EscBusHandle<MotorPolicy>>,
motor: EscMotor,
}
impl BenchEvaluator {
/// 打开链路、启动总线并等待目标电调反馈。
///
/// # Errors
///
/// 链路 / 总线启动失败,或启动超时(电调无反馈)。
pub fn open(link: can::CanLink, config: BenchConfig) -> Result<Self, BenchError> {
let esc_id = config.esc.esc_id;
let effort_limit_raw = config.esc.current_limit;
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.map_err(|e| BenchError::Bus(e.to_string()))?;
let mut policy = MotorPolicy::new();
policy.configure(config.esc.esc_id, config.esc.current_limit, VelocityGains::ZERO);
let bus = EscBus::spawn(link, vec![config.esc.clone()], config.bus.clone(), policy)
.map_err(|e| BenchError::Bus(e.to_string()))?;
let motor = bus
.motor(esc_id)
.ok_or_else(|| BenchError::Bus(format!("esc {esc_id} missing from the bus")))?
.clone();
let startup_timeout = config.startup_timeout;
let ready = runtime.block_on(async {
let deadline = tokio::time::Instant::now() + startup_timeout;
loop {
if motor.feedback_age().is_some_and(|age| age <= Duration::from_millis(100)) {
return true;
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
});
let evaluator = Self {
session: Mutex::new(Session { runtime, bus: Some(bus), motor }),
scenario: config.scenario,
envelope: config.envelope,
effort_limit_raw,
between_trials: config.between_trials,
lease_period: config.lease_period,
};
if !ready {
// 启动失败也必须走完整停机路径:先 0 电流,再关总线。
evaluator.shutdown();
return Err(BenchError::NoFeedback {
esc_id,
waited_ms: startup_timeout.as_millis() as u64,
});
}
Ok(evaluator)
}
/// Scenario executed by each trial.
#[must_use]
pub fn scenario(&self) -> Scenario {
self.scenario
}
/// Stop outputs and close the bus.
pub fn shutdown(&self) {
let mut session =
self.session.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(bus) = session.bus.take() else {
return;
};
for motor in bus.motors() {
motor.disable();
}
session.runtime.block_on(async {
tokio::time::sleep(Duration::from_millis(50)).await;
bus.shutdown().await;
});
}
/// Run one physical trial and return its raw trace and abort reason.
pub fn run_trial(&self, gains: &VelocityGains) -> TrialOutcome {
let mut session =
self.session.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Session { runtime, motor, bus, .. } = &mut *session;
let esc_id = motor.id();
if let Some(bus) = bus.as_ref() {
bus.with_policy(|policy| policy.set_velocity_gains(esc_id, *gains));
}
let scenario = self.scenario;
let envelope = self.envelope.clone();
let limit = self.effort_limit_raw;
let lease = self.lease_period;
let between = self.between_trials;
runtime.block_on(async {
let trial = run_step_trial(
motor,
&scenario,
&envelope,
limit,
lease,
std::future::pending(),
)
.await;
tokio::time::sleep(between).await;
trial
})
}
}
/// 打开真机链路:给了 `interface` 走 SocketCAN,否则走 slcan 串口。
///
/// # Errors
///
/// 设备 / 接口打开失败(路径、权限、接口未 up、平台不支持)时返回错误。
pub fn open_link(
device: &str,
serial_baud: u32,
can_speed: crate::CanSpeed,
interface: Option<&str>,
) -> Result<can::CanLink, BenchError> {
let result = match interface {
Some(interface) => crate::open_socketcan(interface),
None => crate::open_slcan(device, crate::default_slcan_options(serial_baud, can_speed)),
};
result
.with_context(|| match interface {
Some(interface) => {
format!("打开 SocketCAN 接口 {interface} 失败(ip link / 权限?)")
}
None => format!("打开 slcan 设备 {device} 失败(路径 / 权限 / 占用?)"),
})
.map_err(|e| BenchError::Link(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> BenchConfig {
let mut esc = EscConfig::gm3508("bench", 1, 1.0);
esc.current_limit = 1500;
let scenario = Scenario::ramp_and_hold(500.0, 0.05, 0.15);
let envelope = SafetyEnvelope::new()
.with_overspeed(1200.0, Duration::from_millis(60))
.with_temperature_max(70)
.with_feedback_timeout(Duration::from_millis(200))
.with_trial_timeout(Duration::from_secs(2));
BenchConfig::new(esc, scenario, envelope)
}
#[test]
fn config_defaults_are_safe() {
let config = config();
assert_eq!(config.bus.period, Duration::from_millis(1));
assert_eq!(config.bus.command_timeout, Duration::from_millis(50));
assert!(config.between_trials >= Duration::from_millis(200));
assert_eq!(config.lease_period, Duration::from_millis(5), "续租必须远密于 50ms 租约");
}
#[test]
fn link_open_failures_are_errors() {
// 不存在的设备:必须返回 Err 而不是 panic(真机前最常见的失败模式)。
let result =
open_link("/definitely/not/a/device", 2_000_000, crate::CanSpeed::M1, None);
assert!(result.is_err(), "不存在的串口设备必须报错");
let missing_interface =
open_link("ignored", 2_000_000, crate::CanSpeed::M1, Some("can-not-real"));
assert!(missing_interface.is_err(), "不存在的 CAN 接口必须报错");
}
}
}
pub use hil::*;
pub use link::*;
pub use safety::*;
pub use sampler::*;
-75
View File
@@ -1,75 +0,0 @@
//! CAN 传输抽象:帧与链路通道。
//!
//! 本 crate **不含任何协议知识**(无 DJI ID 常量):只定义经典 CAN 数据帧
//! 与一对 tokio 通道构成的链路端点。协议族(`dji-esc` / 未来 `dji-gm6020`)
//! 与总线后端(SocketCAN 桥、仿真器)共享此类型,互不依赖。
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
use tokio::sync::mpsc;
/// 经典 CAN 数据帧(数据长度固定 8 字节)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CanFrame {
/// 11 位标准 ID。
pub id: u32,
/// 数据域。
pub data: [u8; 8],
}
impl CanFrame {
/// 构造一帧。
#[must_use]
pub const fn new(id: u32, data: [u8; 8]) -> Self {
Self { id, data }
}
}
/// CAN 链路端点。
///
/// - `outgoing_tx`:本端发往总线的帧;
/// - `incoming_rx`:本端从总线收到的帧。
///
/// 一条链路由两个端点构成(见 [`CanLink::pair`])。持有本端、把对端交给
/// 对侧(SocketCAN 桥、仿真器)即可通信。
#[derive(Debug)]
pub struct CanLink {
/// 发往总线。
pub outgoing_tx: mpsc::Sender<CanFrame>,
/// 来自总线。
pub incoming_rx: mpsc::Receiver<CanFrame>,
}
impl CanLink {
/// 由已有通道组装端点。
#[must_use]
pub const fn new(
outgoing_tx: mpsc::Sender<CanFrame>,
incoming_rx: mpsc::Receiver<CanFrame>,
) -> Self {
Self { outgoing_tx, incoming_rx }
}
/// 创建一对互联端点:`a` 发出的帧会被 `b` 收到,反之亦然。
///
/// `bound` 为每方向缓冲帧数(建议 ≥ 64,1kHz 收发下留足余量)。
#[must_use]
pub fn pair(bound: usize) -> (Self, Self) {
let (tx_a_to_b, rx_at_b) = mpsc::channel(bound);
let (tx_b_to_a, rx_at_a) = mpsc::channel(bound);
(Self::new(tx_a_to_b, rx_at_a), Self::new(tx_b_to_a, rx_at_b))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn pair_loops_both_directions() {
let (mut a, mut b) = CanLink::pair(8);
a.outgoing_tx.send(CanFrame::new(0x200, [1, 0, 0, 0, 0, 0, 0, 0])).await.unwrap();
b.outgoing_tx.send(CanFrame::new(0x201, [2, 0, 0, 0, 0, 0, 0, 0])).await.unwrap();
assert_eq!(b.incoming_rx.recv().await.unwrap().id, 0x200);
assert_eq!(a.incoming_rx.recv().await.unwrap().id, 0x201);
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "can"
description = "CAN frame, bus interface, channel link, and test bus"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[features]
mock = []
[dependencies]
tokio = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
+463
View File
@@ -0,0 +1,463 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub mod frame {
//! CAN 抽象:经典数据帧与**总线 trait**。
//!
//! [`CanFrame`] 是全系统唯一的帧表示(协议层、驱动层、仿真层、设备层共享),
//! 它**不含任何协议知识**(没有 DJI 帧 ID 常量)。字段与 ARCHITECTURE.md
//! §5.1 一致:`id` / `data` / `dlc` / `is_extended`。
//!
//! [`CanBus`] 是 ARCHITECTURE.md §5.1 定义的硬件访问边界;`driver crates` 为
//! 真实总线实现它,`can::mock` 为假电调实现它,
//! [`CanLink`](crate::link::CanLink) 是它的通道式实现。
/// 经典 CAN 数据帧数据域长度(字节)。
pub const CAN_DATA_LEN: usize = 8;
/// 经典 CAN 数据帧。
///
/// 当前所有设备(C620/C610、GM6020、DETA10)都用「11 位标准 ID + 8 字节」,
/// 但字段保留完整形态:将来接扩展帧或短帧时不必改动所有构造点。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CanFrame {
/// 标准 11 位 / 扩展 29 位 ID。
pub id: u32,
/// 数据域(未使用的字节为 0)。
pub data: [u8; CAN_DATA_LEN],
/// 数据长度码(0..=8)。
pub dlc: u8,
/// 是否扩展帧(29 位 ID)。
pub is_extended: bool,
}
impl CanFrame {
/// 构造一帧标准帧:8 字节数据、`dlc = 8`。
///
/// 这是仓库内**唯一**的常规构造方式;`dlc` / 扩展帧只在特殊场景用
/// [`CanFrame::with_dlc`] / [`CanFrame::extended`] 调整。
#[must_use]
pub const fn new(id: u32, data: [u8; CAN_DATA_LEN]) -> Self {
Self { id, data, dlc: CAN_DATA_LEN as u8, is_extended: false }
}
/// 换一个数据长度码(链式)。
#[must_use]
pub const fn with_dlc(mut self, dlc: u8) -> Self {
self.dlc = if dlc > CAN_DATA_LEN as u8 { CAN_DATA_LEN as u8 } else { dlc };
self
}
/// 标记为扩展帧(链式)。
#[must_use]
pub const fn extended(mut self) -> Self {
self.is_extended = true;
self
}
/// 长度码是否覆盖完整数据域。
#[must_use]
pub const fn is_complete(&self) -> bool {
self.dlc == CAN_DATA_LEN as u8
}
/// ID 是否在 11 位标准帧范围内。
#[must_use]
pub const fn is_standard_id(&self) -> bool {
self.id <= 0x7FF
}
}
/// CAN 总线访问错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CanError {
/// 链路已关闭(对端释放)。
Closed,
/// ID 与帧类型不匹配(如标准帧却给了 29 位 ID)。
BadId(u32),
/// 数据长度码非法(> 8)。
BadDlc(u8),
/// 底层收发失败(驱动自行补充上下文)。
Io,
}
impl core::fmt::Display for CanError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Closed => write!(f, "CAN 链路已关闭"),
Self::BadId(id) => write!(f, "CAN ID {id:#x} 与帧类型不匹配"),
Self::BadDlc(dlc) => write!(f, "CAN 数据长度码 {dlc} 非法(应 ≤ 8)"),
Self::Io => write!(f, "CAN 收发失败"),
}
}
}
impl core::error::Error for CanError {}
/// 硬件无关的 CAN 总线边界(ARCHITECTURE.md §5.1)。
///
/// 只描述"发一帧 / 收一帧 / 非阻塞收一帧",**不规定**底层是 SocketCAN、
/// 串口 slcan、内存通道还是假电调。`async fn in trait` 是本仓库对
/// ARCHITECTURE.md §3.2 的直接采纳(Rust 1.75+ 稳定)。
///
/// # 关于非阻塞
///
/// 控制循环严格要求"读反馈不阻塞"(§6.3 第 2–5 步必须非阻塞)。
/// 通道式实现天然支持 [`CanBus::try_recv`];对不支持的系统调用,
/// 默认实现返回 `Ok(None)` 而不是报错,让上层按"本轮没新帧"处理。
pub trait CanBus {
/// 底层错误类型。
type Error: core::error::Error + Send + Sync + 'static;
/// 发送一帧。
///
/// # Errors
///
/// 链路关闭、帧类型非法或底层写失败时返回错误。
fn send(
&mut self,
frame: &CanFrame,
) -> impl core::future::Future<Output = Result<(), Self::Error>>;
/// 接收一帧(阻塞直到有帧或链路关闭)。
///
/// # Errors
///
/// 链路关闭或底层读失败时返回错误。
fn recv(&mut self) -> impl core::future::Future<Output = Result<CanFrame, Self::Error>>;
/// 非阻塞接收:本轮没有新帧时返回 `Ok(None)`。
///
/// # Errors
///
/// 仅当链路**确定**已关闭 / 损坏时返回错误;"暂时没数据"不是错误。
fn try_recv(&mut self) -> Result<Option<CanFrame>, Self::Error> {
Ok(None)
}
}
/// 帧是否符合"标准 ID + 完整 8 字节"的常规约定。
#[must_use]
pub const fn is_regular_standard_frame(frame: &CanFrame) -> bool {
frame.is_standard_id() && !frame.is_extended && frame.is_complete()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_defaults_to_a_complete_standard_frame() {
let frame = CanFrame::new(0x200, [1, 2, 3, 4, 5, 6, 7, 8]);
assert_eq!(frame.id, 0x200);
assert_eq!(frame.data, [1, 2, 3, 4, 5, 6, 7, 8]);
assert_eq!(frame.dlc, 8);
assert!(!frame.is_extended);
assert!(frame.is_complete());
assert!(is_regular_standard_frame(&frame));
}
#[test]
fn builders_are_chainable_and_clamp_dlc() {
let short = CanFrame::new(0x201, [0; 8]).with_dlc(4);
assert_eq!(short.dlc, 4);
assert!(!short.is_complete());
assert!(!is_regular_standard_frame(&short));
let over = CanFrame::new(0x201, [0; 8]).with_dlc(9);
assert_eq!(over.dlc, 8, "dlc 必须被夹到数据域长度");
let extended = CanFrame::new(0x1_2345, [0; 8]).extended();
assert!(extended.is_extended);
assert!(!is_regular_standard_frame(&extended));
}
#[test]
fn standard_id_boundary() {
assert!(CanFrame::new(0x7FF, [0; 8]).is_standard_id());
assert!(!CanFrame::new(0x800, [0; 8]).is_standard_id());
}
#[test]
fn errors_render_in_chinese() {
let text = CanError::BadId(0x1234).to_string();
assert!(text.contains("0x1234"), "{text}");
assert!(CanError::BadDlc(9).to_string().contains('9'));
assert!(!CanError::Closed.to_string().is_empty());
}
}
}
pub use frame::*;
pub mod link {
//! 通道式总线端点:[`CanLink`] 与它对 [`CanBus`] 的实现。
//!
//! 一对 tokio 通道构成一条链路:本端发出的帧由对端收到,反之亦然。
//! 这是本仓库"**帧传输的唯一接缝**"——协议层与设备层只面向通道工作,
//! 因此同一套协议 / 控制 / 整定代码可以对接真实总线(`driver crates` 的
//! SocketCAN / slcan 桥)或纯内存假电调(`can::mock`),
//! 无需任何条件编译。
use tokio::sync::mpsc;
use crate::frame::{CanBus, CanError, CanFrame};
/// CAN 链路的**一端**。
///
/// - `outgoing_tx`:本端发往总线的帧;
/// - `incoming_rx`:本端从总线收到的帧。
#[derive(Debug)]
pub struct CanLink {
/// 发往总线。
pub outgoing_tx: mpsc::Sender<CanFrame>,
/// 来自总线。
pub incoming_rx: mpsc::Receiver<CanFrame>,
}
impl CanLink {
/// 由已有通道组装端点。
#[must_use]
pub const fn new(
outgoing_tx: mpsc::Sender<CanFrame>,
incoming_rx: mpsc::Receiver<CanFrame>,
) -> Self {
Self { outgoing_tx, incoming_rx }
}
/// 创建一对互联端点:`a` 发出的帧会被 `b` 收到,反之亦然。
///
/// `bound` 为每方向缓冲帧数。命令方向必须**浅**(1kHz 生产、总线消费
/// 不过来时靠 `send().await` 回压,见 `slcan` 的说明);
/// 反馈方向是采样流,可以深一些。
#[must_use]
pub fn pair(bound: usize) -> (Self, Self) {
let (tx_a_to_b, rx_at_b) = mpsc::channel(bound);
let (tx_b_to_a, rx_at_a) = mpsc::channel(bound);
(Self::new(tx_a_to_b, rx_at_a), Self::new(tx_b_to_a, rx_at_b))
}
}
impl CanBus for CanLink {
type Error = CanError;
async fn send(&mut self, frame: &CanFrame) -> Result<(), Self::Error> {
if frame.dlc > 8 {
return Err(CanError::BadDlc(frame.dlc));
}
self.outgoing_tx.send(*frame).await.map_err(|_| CanError::Closed)
}
async fn recv(&mut self) -> Result<CanFrame, Self::Error> {
self.incoming_rx.recv().await.ok_or(CanError::Closed)
}
fn try_recv(&mut self) -> Result<Option<CanFrame>, Self::Error> {
match self.incoming_rx.try_recv() {
Ok(frame) => Ok(Some(frame)),
Err(mpsc::error::TryRecvError::Empty) => Ok(None),
Err(mpsc::error::TryRecvError::Disconnected) => Err(CanError::Closed),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn pair_loops_both_directions() {
let (mut a, mut b) = CanLink::pair(8);
a.outgoing_tx.send(CanFrame::new(0x200, [1, 0, 0, 0, 0, 0, 0, 0])).await.unwrap();
b.outgoing_tx.send(CanFrame::new(0x201, [2, 0, 0, 0, 0, 0, 0, 0])).await.unwrap();
assert_eq!(b.incoming_rx.recv().await.unwrap().id, 0x200);
assert_eq!(a.incoming_rx.recv().await.unwrap().id, 0x201);
}
#[tokio::test]
async fn can_bus_impl_sends_receives_and_polls() {
let (mut a, mut b) = CanLink::pair(8);
assert_eq!(a.try_recv().unwrap(), None, "空通道不是错误");
a.send(&CanFrame::new(0x1FF, [7; 8])).await.unwrap();
assert_eq!(b.try_recv().unwrap().map(|f| f.id), Some(0x1FF));
assert_eq!(b.try_recv().unwrap(), None, "取走之后就没有了");
}
#[tokio::test]
async fn recv_reports_closed_link() {
let (mut a, b) = CanLink::pair(8);
drop(b);
assert_eq!(a.recv().await.unwrap_err(), CanError::Closed);
assert_eq!(a.try_recv().unwrap_err(), CanError::Closed);
}
#[tokio::test]
async fn send_rejects_illegal_dlc() {
let (mut a, _b) = CanLink::pair(8);
let bad = CanFrame::new(0x200, [0; 8]).with_dlc(8).with_dlc(8);
assert!(a.send(&bad).await.is_ok());
// `with_dlc` 本身会夹住越界值,因此非法 dlc 只能由手工构造的帧产生。
let mut manual = CanFrame::new(0x200, [0; 8]);
manual.dlc = 9;
assert_eq!(a.send(&manual).await.unwrap_err(), CanError::BadDlc(9));
}
}
}
pub use link::*;
pub mod mock {
//! 驱动级测试替身:[`MockBus`](`mock` feature)。
//!
//! **这里没有电机模型**——那是 `sim-harness` 的事(`sim-harness::rig` 的异步假电调带
//! 摩擦 / 延迟物理模型)。本模块只做驱动层该做的事:
//!
//! - 记录所有**发出去**的帧(断言"上层到底写了什么");
//! - 按需**注入**要收到的帧(模拟总线上的反馈);
//! - 可以设置成"链路已断开",用于测试离线 / 安全路径。
//!
//! 有了它,协议层与设备层的测试不必启动 tokio 任务,也不必构造真实总线。
use std::collections::VecDeque;
use crate::frame::{CanBus, CanError, CanFrame};
/// 记录发送、可注入接收的假总线。
#[derive(Debug, Default)]
pub struct MockBus {
/// 上层通过 [`CanBus::send`] 发出的帧(按时间顺序)。
sent: Vec<CanFrame>,
/// 等待被上层收到的帧。
pending: VecDeque<CanFrame>,
/// 置位后所有操作都返回 [`CanError::Closed`]。
closed: bool,
}
impl MockBus {
/// 空总线。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 注入一帧"来自总线"的数据。
pub fn inject(&mut self, frame: CanFrame) {
self.pending.push_back(frame);
}
/// 模拟链路断开。
pub fn close(&mut self) {
self.closed = true;
}
/// 已发出的帧。
#[must_use]
pub fn sent(&self) -> &[CanFrame] {
&self.sent
}
/// 最近发出的一帧。
#[must_use]
pub fn last_sent(&self) -> Option<&CanFrame> {
self.sent.last()
}
/// 清空发送记录。
pub fn clear_sent(&mut self) {
self.sent.clear();
}
/// 还剩多少帧没被取走。
#[must_use]
pub fn pending_len(&self) -> usize {
self.pending.len()
}
}
impl CanBus for MockBus {
type Error = CanError;
// 这里用 `std::future::ready` 而不是 `async fn`:内存总线没有真正的等待,
// 返回一个已完成的 future 更诚实(也避免"async 函数里没有 await"的告警)。
fn send(
&mut self,
frame: &CanFrame,
) -> impl std::future::Future<Output = Result<(), Self::Error>> {
let result = if self.closed {
Err(CanError::Closed)
} else if frame.dlc > 8 {
Err(CanError::BadDlc(frame.dlc))
} else if !frame.is_standard_id() && !frame.is_extended {
Err(CanError::BadId(frame.id))
} else {
self.sent.push(*frame);
Ok(())
};
std::future::ready(result)
}
fn recv(&mut self) -> impl std::future::Future<Output = Result<CanFrame, Self::Error>> {
let result = if self.closed {
Err(CanError::Closed)
} else {
self.pending.pop_front().ok_or(CanError::Closed)
};
std::future::ready(result)
}
fn try_recv(&mut self) -> Result<Option<CanFrame>, Self::Error> {
if self.closed {
return Err(CanError::Closed);
}
Ok(self.pending.pop_front())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn records_sent_frames_in_order() {
let mut bus = MockBus::new();
bus.send(&CanFrame::new(0x200, [1; 8])).await.unwrap();
bus.send(&CanFrame::new(0x200, [2; 8])).await.unwrap();
assert_eq!(bus.sent().len(), 2);
assert_eq!(bus.last_sent().unwrap().data[0], 2);
bus.clear_sent();
assert!(bus.sent().is_empty());
}
#[tokio::test]
async fn injected_frames_come_back_once() {
let mut bus = MockBus::new();
bus.inject(CanFrame::new(0x201, [9; 8]));
assert_eq!(bus.pending_len(), 1);
let frame = bus.recv().await.unwrap();
assert_eq!(frame.id, 0x201);
assert_eq!(bus.try_recv().unwrap(), None, "取走之后就没有了");
}
#[tokio::test]
async fn closed_bus_reports_errors_on_every_path() {
let mut bus = MockBus::new();
bus.close();
assert_eq!(
bus.send(&CanFrame::new(0x200, [0; 8])).await.unwrap_err(),
CanError::Closed
);
assert_eq!(bus.recv().await.unwrap_err(), CanError::Closed);
assert_eq!(bus.try_recv().unwrap_err(), CanError::Closed);
}
#[tokio::test]
async fn illegal_dlc_is_rejected_before_recording() {
let mut bus = MockBus::new();
let mut bad = CanFrame::new(0x200, [0; 8]);
bad.dlc = 9;
assert_eq!(bus.send(&bad).await.unwrap_err(), CanError::BadDlc(9));
assert!(bus.sent().is_empty(), "非法帧不得进入记录");
}
}
}
pub use mock::*;
-32
View File
@@ -1,32 +0,0 @@
[package]
name = "chassis-bench"
description = "Bench harness: stimulus, safety envelope, trial sampling, and the bench binaries"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
# 二进制从 `src/bin/` 自动发现:
# - `chassis-spin`:四轮定速旋转(slcan,全平台)
# - `socketcan-spin`:单电机验证(SocketCAN,仅 Linux)
[dependencies]
can-link = { workspace = true }
motor-core = { workspace = true }
dji-esc = { workspace = true }
dji-esc-sim = { workspace = true }
dji-esc-slcan = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
clap = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
dji-esc-socketcan = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
@@ -1,554 +0,0 @@
//! 实车台架工具:让**四个轮子同时**以固定**转子**转速旋转(默认 500 rpm)。
//!
//! ```sh
//! # T153 板载 CANable2(slcan 固件)→ /dev/ttyACM0,四轮 500 rpm 转子,缓升 1s 保持 10s
//! cargo run --release -p chassis-bench --bin chassis-spin -- \
//! --device /dev/ttyACM0 --rpm 500
//!
//! # 只验证链路与反馈,不发任何非零电流(接线排查用)
//! cargo run --release -p chassis-bench --bin chassis-spin -- --device /dev/ttyACM0 --dry-run
//! ```
//!
//! # 抽象边界
//!
//! 本工具是**组合根**:`dji-esc-slcan`(链路桥接)→ `dji-esc`(C620 协议 +
//! 速度环)→ `motor-core`(`VelocityDrive` / `MotorFeedback` / `Disable` 能力
//! trait)。激励场景与安全包络来自同 crate 的 `chassis-bench` 库,与
//! `chassis-tune` 的真机后端**共用同一套实现**。
//!
//! # 命令租约(重要)
//!
//! `EscBus` 的命令是**租约**:`command_timeout`(默认 50ms)内没有重新下发命令,
//! 驱动会强制 0 电流。所以控制循环必须**逐拍重发**同一个设定值——本工具在 1kHz
//! 节拍里重发(与 `chassisd` 的控制循环一致)。只在开头下发一次是**不行的**。
//!
//! # 安全
//!
//! 轮子必须架空或整车固定,急停可及。默认低限流、有限时长、ctrl-c 任意时刻
//! 安全停机(先 0 电流再关总线);未收到全部反馈前绝不下发速度。
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::process::ExitCode;
use std::time::Duration;
use chassis_bench::{SafetyEnvelope, SafetyMonitor, Scenario};
use clap::{Parser, ValueEnum};
use dji_esc::{BusOptions, EscBus, EscBusHandle, EscConfig, EscMotor};
use dji_esc_slcan::{CanSpeed, SlcanOptions, open_link_with};
use motor_core::{Disable, MotorFeedback, VelocityDrive};
use tokio::time::{Instant, MissedTickBehavior, interval, sleep};
/// 稳态判据:平均转子转速与目标的相对误差上限。
const TOLERANCE: f32 = 0.05;
/// 状态打印周期。
const REPORT_PERIOD: Duration = Duration::from_millis(200);
/// 反馈新鲜度阈值:`feedback_age` 超过它即视为该电调掉线。
const FRESH: Duration = Duration::from_millis(100);
/// 过速需要持续这么久才判定为失控并停机(避免单帧噪声误触发)。
const OVERSPEED_GRACE: Duration = Duration::from_millis(60);
/// 控制节拍(C620 期望 1kHz 命令)。
const CONTROL_PERIOD: Duration = Duration::from_millis(1);
/// 四轮默认名(顺序固定 FL、FR、RL、RR)。
const WHEEL_NAMES: [&str; 4] = ["front_left", "front_right", "rear_left", "rear_right"];
#[derive(Debug, Parser)]
#[command(name = "chassis-spin", version, about = "台架:四轮同时旋转(默认 500 rpm 转子)")]
struct Args {
/// slcan 串口设备(T153 板载 CANable2 为 /dev/ttyACM0)。
#[arg(long, default_value = "/dev/ttyACM0")]
device: String,
/// 串口波特率(CDC ACM 一般忽略;CH340 类适配器需与固件一致)。
#[arg(long, default_value_t = 2_000_000)]
serial_baud: u32,
/// 串口超时(毫秒)。读写共用:`serial` 的 `TTYPort::write` 也按它 poll(POLLOUT)。
#[arg(long, default_value_t = 50)]
serial_timeout_ms: u64,
/// CAN 波特率档位(C620 标准 = 1Mbps)。
#[arg(long, value_enum, default_value_t = CanSpeedArg::M1)]
can_speed: CanSpeedArg,
/// 电调 ID 列表,顺序 = FL、FR、RL、RR。
#[arg(long, value_delimiter = ',', default_value = "1,2,3,4")]
esc_ids: Vec<u8>,
/// 各轮安装方向 ±1(默认左右镜像 → 四轮物理同向)。
#[arg(long, value_delimiter = ',', allow_hyphen_values = true, default_value = "1,-1,1,-1")]
directions: Vec<f32>,
/// 目标**转子**转速(rpm);轮端 = 转子 ÷ 减速比。
#[arg(long, default_value_t = 500.0)]
rpm: f32,
/// 减速比(转子 : 输出轴,GM3508 ≈ 19.203)。
#[arg(long, default_value_t = 19.203)]
gear_ratio: f32,
/// 电流限幅(raw,C620 满量程 16384)。
///
/// 默认 1500 raw(≈1.8A):要越过静摩擦(实测约 400~600 raw)且留出整定余量。
/// 上限同时也是 PID 抗饱和的钳位点——**必须贴近实际所需电流**,
/// 否则积分会在堵转期间积累,一旦起动就冲过头。
#[arg(long, default_value_t = 1500)]
current_limit: i16,
/// 缓升时间:0 → 目标转速(毫秒)。
///
/// 默认 3000:空载 GM3508 的静摩擦需要约 400~600 raw 才能起动(stick-slip),
/// 缓升让积分在起动前平滑建立,避免"先憋住、一动就飞"。
#[arg(long, default_value_t = 3000)]
ramp_ms: u64,
/// 目标转速保持时长(秒)。
#[arg(long, default_value_t = 10.0)]
hold_secs: f32,
/// 等待全部电调反馈的超时(毫秒)。
#[arg(long, default_value_t = 3000)]
startup_timeout_ms: u64,
/// 只验证链路与反馈,不发出任何非零电流。
#[arg(long)]
dry_run: bool,
/// 允许缺失部分电调:仅驱动有反馈的电调继续测试(默认要求四个电调全部在线)。
#[arg(long)]
allow_partial: bool,
/// 速度环 kp(raw/rpm)。
///
/// 空载台架默认 2.0:被控对象近似积分器(≈12 rpm/s per raw),
/// 高 kp(如 8~18)会因反馈延迟震荡;低 kp 配合大 ki 才稳。
#[arg(long, default_value_t = 2.0)]
kp: f32,
/// 速度环 ki(raw/(rpm·s))。
///
/// 空载台架默认 10:静摩擦需要积分项缓慢"顶"到起动电流,ki 太小会长期
/// 停在 300~400 rpm 达不到目标;抗饱和由 `current_limit` 钳位兜底,
/// 所以大 ki 不会跑飞。
#[arg(long, default_value_t = 10.0)]
ki: f32,
/// 速度环 kd。
#[arg(long, default_value_t = 0.0)]
kd: f32,
/// 速度前馈(raw/rpm)。
///
/// **空载台架默认 0**:轮子架空时维持转速只需要克服摩擦的极小电流,
/// 任何接近 1/K(GM3508 空载 K ≈ 0.57 rpm/raw → 1/K ≈ 1.77)的前馈都会把
/// 空载轮子直接推到最高转速。只有带载工况才需要正前馈。
#[arg(long, default_value_t = 0.0)]
feedforward: f32,
/// 过速保护上限(rpm):任一参与测试的电调超过它即立即停机。
/// 默认 = 目标 × 2 + 200。
#[arg(long)]
max_rpm: Option<f32>,
}
/// CAN 波特率档位(对应 slcan 的 `Sn` 命令)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CanSpeedArg {
/// `S8`:1 Mbps。
#[value(name = "1m")]
M1,
/// `S6`:500 kbps。
#[value(name = "500k")]
K500,
}
impl From<CanSpeedArg> for CanSpeed {
fn from(value: CanSpeedArg) -> Self {
match value {
CanSpeedArg::M1 => Self::M1,
CanSpeedArg::K500 => Self::K500,
}
}
}
impl Args {
/// 过速保护上限:显式 `--max-rpm` 优先,否则目标 × 2 + 200。
fn overspeed_limit(&self) -> f32 {
self.max_rpm.unwrap_or(self.rpm * 2.0 + 200.0)
}
/// 参数自检:越界参数在碰硬件之前就拒绝。
fn validate(&self) -> Result<(), String> {
if self.esc_ids.is_empty() {
return Err("--esc-ids 不能为空".into());
}
if self.esc_ids.len() != self.directions.len() {
return Err(format!(
"--esc-ids({} 个)与 --directions({} 个)数量必须一致",
self.esc_ids.len(),
self.directions.len()
));
}
for &id in &self.esc_ids {
if !(1..=8).contains(&id) {
return Err(format!("电调 ID {id} 超出 1..=8"));
}
}
for &direction in &self.directions {
let bits = direction.to_bits();
if bits != 1.0f32.to_bits() && bits != (-1.0f32).to_bits() {
return Err(format!("方向必须是 +1 或 -1,得到 {direction}"));
}
}
if !(self.rpm.is_finite() && self.rpm >= 0.0) {
return Err(format!("--rpm 必须 ≥ 0,得到 {}", self.rpm));
}
if let Some(max) = self.max_rpm
&& !(max.is_finite() && max > self.rpm)
{
return Err(format!("--max-rpm 必须大于目标转速 {},得到 {max}", self.rpm));
}
if !(self.gear_ratio.is_finite() && self.gear_ratio > 0.0) {
return Err(format!("--gear-ratio 必须 > 0,得到 {}", self.gear_ratio));
}
if !(1..=16384).contains(&self.current_limit) {
return Err(format!("--current-limit 必须在 1..=16384,得到 {}", self.current_limit));
}
if self.hold_secs < 0.0 {
return Err(format!("--hold-secs 不能为负,得到 {}", self.hold_secs));
}
Ok(())
}
/// 四个电调配置(方向、减速比、电流限幅、PID 全部显式落地)。
fn esc_configs(&self) -> Vec<EscConfig> {
self.esc_ids
.iter()
.enumerate()
.map(|(index, &id)| {
let name = WHEEL_NAMES
.get(index)
.map_or_else(|| format!("wheel{id}"), |name| (*name).to_owned());
let mut cfg = EscConfig::gm3508(name, id, self.directions[index]);
cfg.gear_ratio = self.gear_ratio;
cfg.current_limit = self.current_limit;
cfg.kp = self.kp;
cfg.ki = self.ki;
cfg.kd = self.kd;
cfg.feedforward = self.feedforward;
cfg
})
.collect()
}
}
fn bus_options() -> BusOptions {
BusOptions {
period: CONTROL_PERIOD,
feedback_timeout: Duration::from_millis(100),
// 命令租约 50ms;控制循环每 1ms 续租。
command_timeout: Duration::from_millis(50),
}
}
#[tokio::main]
async fn main() -> ExitCode {
init_tracing();
let args = Args::parse();
if let Err(problem) = args.validate() {
eprintln!("✗ 参数错误:{problem}");
return ExitCode::FAILURE;
}
print_banner(&args);
match run(&args).await {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::FAILURE,
Err(e) => {
eprintln!("✗ 运行失败:{e}");
ExitCode::FAILURE
}
}
}
/// 初始化日志:链路桥的读写失败/静默告警必须可见,
/// 否则现场只剩一句"没有反馈",无法区分适配器故障与整车接线问题。
fn init_tracing() {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn,dji_esc_slcan=debug"));
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
}
fn print_banner(args: &Args) {
println!("=== 台架:四轮同时旋转(转子目标 {:.1} rpm)===", args.rpm);
println!(
"设备 {} | CAN {:?} | 电调 {:?} | 方向 {:?} | 减速比 {:.3}",
args.device, args.can_speed, args.esc_ids, args.directions, args.gear_ratio
);
println!(
"转子 {:.1} rpm ÷ {:.3} = 轮端 {:.2} rpm | 电流限幅 {} raw | 缓升 {}ms | 保持 {:.1}s{}",
args.rpm,
args.gear_ratio,
args.rpm / args.gear_ratio,
args.current_limit,
args.ramp_ms,
args.hold_secs,
if args.dry_run {
" | dry-run"
} else if args.allow_partial {
" | allow-partial"
} else {
""
}
);
if args.dry_run {
println!("ℹ dry-run:只读反馈,不发出非零电流。\n");
} else {
println!(
"速度环 kp={} ki={} kd={} ff={} | 过速保护 {:.0} rpm",
args.kp,
args.ki,
args.kd,
args.feedforward,
args.overspeed_limit()
);
println!("⚠ 轮子必须架空或整车固定,急停可及;ctrl-c 随时安全停机。\n");
}
}
/// 打开链路 → 装配协议 bus → 执行 → **任何路径都先停电流再关总线**。
async fn run(args: &Args) -> Result<bool, Box<dyn std::error::Error>> {
let options = SlcanOptions {
serial_baud: args.serial_baud,
speed: args.can_speed.into(),
// 读写共用该超时(serial crate 的 TTYPort::write 也先 poll(POLLOUT)):
// 设太小会把"这一拍写不出去"误判成写失败。总线繁忙时读立即返回,
// 放大它只影响总线完全静默时的下行延迟。
read_timeout: Duration::from_millis(args.serial_timeout_ms),
};
let link = open_link_with(&args.device, options).map_err(|e| {
format!("打开 slcan 设备 {} 失败:{e}(检查设备路径 / 权限 / 是否被占用)", args.device)
})?;
println!("✓ slcan 已打开:{}(CAN {:?})", args.device, args.can_speed);
let bus = EscBus::spawn(link, args.esc_configs(), bus_options())?;
let outcome = drive(args, &bus).await;
// 安全收尾:先 0 电流并让它至少发出一拍,再关总线。
for motor in bus.motors() {
motor.disable();
}
sleep(Duration::from_millis(50)).await;
bus.shutdown().await;
println!("已停机(0 电流)并关闭总线");
outcome
}
async fn drive(args: &Args, bus: &EscBusHandle) -> Result<bool, Box<dyn std::error::Error>> {
let motors = bus.motors();
wait_for_feedback(motors, Duration::from_millis(args.startup_timeout_ms)).await;
let targets: Vec<EscMotor> = motors.iter().filter(|m| is_fresh(m)).cloned().collect();
let missing: Vec<u8> = motors.iter().filter(|m| !is_fresh(m)).map(MotorFeedback::id).collect();
if !missing.is_empty() {
eprintln!(
"✗ 以下电调没有新鲜反馈:{missing:?}(收到 {:?})",
motors.iter().map(MotorFeedback::feedback_age).collect::<Vec<_>>()
);
eprintln!(
" 排查:CAN 波特率(C620 = 1Mbps = S8)| CANH/CANL 是否接反 | 120Ω 终端电阻 | 电调供电 | 电调 CAN ID 拨码 | 设备 {}",
args.device
);
if !args.allow_partial {
eprintln!(" 若确认台架只接了部分电调,可加 --allow-partial 降级测试。");
return Ok(false);
}
eprintln!("⚠ --allow-partial:仅驱动有反馈的电调继续测试。\n");
}
if targets.is_empty() {
eprintln!("✗ 没有任何电调在反馈,无法测试。");
return Ok(false);
}
println!(
"✓ 参与测试的电调:{:?}(新鲜度 < {FRESH:?})\n",
targets.iter().map(MotorFeedback::id).collect::<Vec<_>>()
);
if args.dry_run {
return Ok(dry_run(&targets).await);
}
Ok(spin(args, &targets).await)
}
/// 该电调是否有**新鲜**反馈(而非历史上有过反馈)。
fn is_fresh(motor: &EscMotor) -> bool {
motor.feedback_age().is_some_and(|age| age <= FRESH)
}
/// 上电门控:等到每个电调都有一帧**新鲜**反馈;超时即返回 `false`。
async fn wait_for_feedback(motors: &[EscMotor], timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if motors.iter().all(is_fresh) {
return true;
}
if Instant::now() >= deadline {
return false;
}
sleep(Duration::from_millis(20)).await;
}
}
/// dry-run:只观察反馈,不下发任何非零电流(总线仍在发 0 电流帧)。
async fn dry_run(targets: &[EscMotor]) -> bool {
let mut ticker = interval(REPORT_PERIOD);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
ticker.tick().await;
for motor in targets {
let st = motor.state();
println!(
" {:<11} esc{} | 转子 {:>7.1} rpm | 温度 {:>3}℃ | 反馈 {:>6} 帧 | 新鲜度 {:?}",
motor.name(),
motor.id(),
st.rotor_speed_rpm,
st.temperature_c,
st.feedback_count,
motor.feedback_age().map(|age| age.as_millis())
);
}
println!();
}
println!("✓ 链路与反馈验证完成(未发出非零电流)");
true
}
/// 本次台架的安全包络:只开过速保护(`chassis-spin` 的历史行为)。
fn envelope(args: &Args) -> SafetyEnvelope {
SafetyEnvelope::overspeed_only(args.overspeed_limit(), OVERSPEED_GRACE)
}
/// 缓升 → 保持 → 采样;返回稳态是否达标。
async fn spin(args: &Args, motors: &[EscMotor]) -> bool {
let ramp = Duration::from_millis(args.ramp_ms);
let hold = Duration::from_secs_f32(args.hold_secs);
let overspeed_limit = args.overspeed_limit();
// 设定值曲线与安全判定都来自 chassis-bench 库:与 chassis-tune 的真机
// 后端共用同一套实现,避免"台架测的行为"和"整定优化的行为"分叉。
let scenario = Scenario::ramp_and_hold(args.rpm, ramp.as_secs_f32(), hold.as_secs_f32());
let mut ticker = interval(CONTROL_PERIOD);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let start = Instant::now();
let mut monitor = SafetyMonitor::new(envelope(args), start);
let mut next_report = REPORT_PERIOD;
let mut samples: Vec<Vec<f32>> = vec![Vec::new(); motors.len()];
let mut overspeed_warned = false;
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
println!("\n收到 ctrl-c,中断测试");
return false;
}
_ = ticker.tick() => {}
}
let elapsed = start.elapsed();
if elapsed >= ramp + hold {
break;
}
// 逐拍重发(= 命令租约续期),斜坡段目标线性上升。
let setpoint = scenario.setpoint_at(elapsed.as_secs_f32());
for motor in motors {
motor.set_velocity(setpoint);
}
// 过速保护:控制器失稳时立刻停机,别让轮子飞到最高转速。
let peak = motors.iter().map(|m| m.state().rotor_speed_rpm.abs()).fold(0.0f32, f32::max);
if peak > overspeed_limit && !overspeed_warned {
overspeed_warned = true;
eprintln!("⚠ 过速 {peak:.0} rpm > 上限 {overspeed_limit:.0} rpm,观察中……");
}
if let Some(abort) = monitor.check(motors, Instant::now()) {
eprintln!(
"✗ 过速保护触发:{peak:.0} rpm 超过上限 {overspeed_limit:.0} rpm,立即停机({abort})。\n 速度环整定不当(kp/ki/前馈过大)——空载台架前馈应为 0。"
);
return false;
}
if elapsed >= ramp {
for (slot, motor) in samples.iter_mut().zip(motors) {
slot.push(motor.state().rotor_speed_rpm);
}
}
if elapsed >= next_report {
report_line(motors, setpoint, elapsed);
next_report += REPORT_PERIOD;
}
}
summarize(args, motors, &samples)
}
fn report_line(motors: &[EscMotor], setpoint: f32, elapsed: Duration) {
let rotor: Vec<String> =
motors.iter().map(|m| format!("{:>7.1}", m.state().rotor_speed_rpm)).collect();
let current: Vec<String> =
motors.iter().map(|m| format!("{:>6}", m.state().effort_raw)).collect();
let temp: Vec<String> =
motors.iter().map(|m| format!("{:>3}", m.state().temperature_c)).collect();
println!(
"[t={:>5.1}s] 目标 {:>7.1} | 转子 {} | 电流 {} | 温度 {}",
elapsed.as_secs_f32(),
setpoint,
rotor.join(" "),
current.join(" "),
temp.join(" ")
);
}
fn summarize(args: &Args, motors: &[EscMotor], samples: &[Vec<f32>]) -> bool {
if samples.iter().all(Vec::is_empty) {
println!("保持时长为 0,未采集到稳态样本");
return true;
}
println!(
"\n=== 稳态结论(目标转子 {:.1} rpm ≈ 轮端 {:.2} rpm)===",
args.rpm,
args.rpm / args.gear_ratio
);
let mut all_ok = true;
for (index, motor) in motors.iter().enumerate() {
let mean = mean(&samples[index]);
let error = if args.rpm.abs() > f32::EPSILON {
(mean - args.rpm).abs() / args.rpm.abs()
} else {
0.0
};
let ok = error <= TOLERANCE;
all_ok &= ok;
println!(
" {:<11} esc{} | 稳态 {:>7.1} rpm | 轮端 {:>6.2} rpm | 误差 {:>5.1}% | 电流 {:>6} raw | 温度 {:>3}℃ | {}",
motor.name(),
motor.id(),
mean,
mean / args.gear_ratio,
error * 100.0,
motor.state().effort_raw,
motor.state().temperature_c,
if ok { "✓" } else { "✗" }
);
}
if all_ok {
println!("\n✓ 通过:{} 个轮子均达到目标转速 ±{:.0}%", motors.len(), TOLERANCE * 100.0);
} else {
println!(
"\n✗ 未通过:存在轮速偏差 > {:.0}%,检查 PID / 机械阻力 / 电流限幅 / 方向配置",
TOLERANCE * 100.0
);
}
all_ok
}
fn mean(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
samples.iter().sum::<f32>() / samples.len() as f32
}
@@ -1,121 +0,0 @@
//! 单电机验证工具:让一个 C620 / GM3508 转起来,验证电机抽象全链路。
//!
//! ```sh
//! socketcan-spin [interface] [esc_id] [rotor_rpm] [duration_secs]
//! # 例:can0 上电调 1 以 1000 rpm(转子)转 10 秒
//! cargo run --release -p chassis-bench --bin socketcan-spin -- can0 1 1000 10
//! ```
//!
//! 关键点在 `spin_one`:它只依赖 `motor_core::Motor` 抽象,完全不知道
//! 背后是 C620 协议还是别的电机——换任何实现该 trait 的电机,这段代码
//! 原样可用。抽象与协议实现(`dji-esc`)、总线后端(`dji-esc-socketcan`)在此汇合。
//!
//! 仅 Linux(需要 SocketCAN);其他平台编译为报错桩。
#![allow(clippy::unwrap_used, clippy::expect_used)]
#[cfg(target_os = "linux")]
use std::time::Duration;
#[cfg(target_os = "linux")]
use dji_esc::{BusOptions, EscBus, EscConfig};
#[cfg(target_os = "linux")]
use dji_esc_socketcan::open_link;
#[cfg(target_os = "linux")]
use motor_core::Motor;
#[cfg(target_os = "linux")]
use tokio::time::{MissedTickBehavior, interval};
#[cfg(target_os = "linux")]
#[tokio::main]
async fn main() {
let mut args = std::env::args().skip(1);
let interface = args.next().unwrap_or_else(|| "can0".into());
let esc_id: u8 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1);
let rotor_rpm: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1000.0);
let duration_secs: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(10.0);
println!("=== 单电机抽象验证 ===");
println!(
"接口 {interface} | 电调 {esc_id} | 目标 {rotor_rpm} rpm(转子)| 时长 {duration_secs}s"
);
let link = open_link(&interface).unwrap_or_else(|e| {
eprintln!(
"打开 {interface} 失败:{e}(先 ip link set {interface} up type can bitrate 1000000)"
);
std::process::exit(1);
});
let config = EscConfig::gm3508("spin_test", esc_id, 1.0);
println!(
"电机配置:{:?} | 减速比 {:.3} | 电流限幅 {}",
config.model, config.gear_ratio, config.current_limit
);
let bus = EscBus::spawn(link, vec![config], BusOptions::default()).expect("EscBus 配置非法");
let motor = bus.motor(esc_id).expect("刚配置的电机必然存在");
spin_one(motor, rotor_rpm, Duration::from_secs_f32(duration_secs)).await;
bus.shutdown().await;
println!("已退出");
}
/// 验证目标:本函数只面向 [`Motor`] 抽象——设定转速、周期读状态、停机。
/// 它不知道 CAN,不知道 C620,换任何 Motor 实现都原样工作。
///
/// 注意**命令租约**:`BusOptions::command_timeout` 默认 50ms,超过该时长未重发
/// 命令,驱动会强制 0 电流。因此这里用 10ms 的租约拍持续重发同一个设定值
/// (真实底盘的控制循环每 1ms 重发一次)。
#[cfg(target_os = "linux")]
async fn spin_one(motor: &impl Motor, target_rpm: f32, duration: Duration) {
const LEASE_PERIOD: Duration = Duration::from_millis(10);
const REPORT_PERIOD: Duration = Duration::from_millis(100);
motor.set_velocity(target_rpm);
println!("已下发目标转速,开始观测(100ms 周期)……\n");
let mut lease = interval(LEASE_PERIOD);
lease.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut report = interval(REPORT_PERIOD);
report.set_missed_tick_behavior(MissedTickBehavior::Skip);
let deadline = tokio::time::Instant::now() + duration;
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
println!("\n收到 ctrl-c");
break;
}
_ = lease.tick() => motor.set_velocity(target_rpm), // 续租
_ = report.tick() => {
let st = motor.state();
if st.has_feedback() {
println!(
"转子 {:>8.1} rpm | effort {:>6} raw | 温度 {:>3} ℃ | 累计角度 {:>8.3} 圈 | 反馈 {} 帧",
st.rotor_speed_rpm,
st.effort_raw,
st.temperature_c,
st.rotor_revolutions,
st.feedback_count
);
} else {
println!("⚠ 尚未收到任何反馈——检查电调供电与 CAN 接线");
}
}
}
if tokio::time::Instant::now() >= deadline {
break;
}
}
motor.disable();
// 让 0 电流帧至少发出一拍再关总线。
tokio::time::sleep(Duration::from_millis(50)).await;
println!("已停机(0 电流)");
}
#[cfg(not(target_os = "linux"))]
fn main() {
eprintln!("spin 仅支持 Linux(需要 SocketCAN)");
}
-27
View File
@@ -1,27 +0,0 @@
//! 台架工具库:激励场景、安全包络与真机试验采样。
//!
//! 本 crate 是**台架相关一切的唯一归属**:以前 `chassis-spin` 是
//! `dji-esc-slcan` 的一个 example、`spin` 是 `dji-esc-socketcan` 的一个
//! example——运输后端 crate 却装着应用级工具。现在:
//!
//! - 运输 crate(`dji-esc-slcan` / `dji-esc-socketcan`)只做桥接;
//! - 台架程序与可复用的安全 / 采样逻辑住在这里;
//! - `chassis-tune` 的真机后端复用 [`sampler`] 与 [`safety`],不重复实现。
//!
//! 二进制:
//!
//! - `chassis-spin`:四轮定速旋转(slcan,全平台);
//! - `socketcan-spin`:单电机链路验证(SocketCAN,仅 Linux);
//! - [`link`]:两种运输后端的统一门面(台架与真机整定共用)。
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub mod link;
pub mod safety;
pub mod sampler;
pub use link::{CanSpeed, SlcanOptions, default_slcan_options, open_slcan, open_socketcan};
pub use safety::{Abort, SafetyEnvelope, SafetyMonitor};
pub use sampler::{TrialOutcome, run_step_trial, run_step_trial_until_ctrl_c};
/// 与离线 harness 共用的场景 / 轨迹类型(同一套实验词汇)。
pub use dji_esc_sim::{Scenario, SetpointProfile, Trace};
-87
View File
@@ -1,87 +0,0 @@
//! 链路打开:把两种运输后端收敛成一个门面。
//!
//! 台架工具与自动整定的真机后端都需要"给个设备名,拿回一条 [`CanLink`]",
//! 而不关心底层是 slcan 串口还是 SocketCAN。把选择留在这一处,上层就不必
//! 同时依赖两个运输 crate。
use std::io::Result;
use std::time::Duration;
use can_link::CanLink;
/// slcan 波特率档位(对应协议 `Sn` 命令)。
pub use dji_esc_slcan::CanSpeed;
/// slcan 打开参数。
pub use dji_esc_slcan::SlcanOptions;
/// 打开 slcan 串口设备(CANable 等),桥接为链路端点。
///
/// # Errors
///
/// 设备不存在、权限不足或波特率不被支持时的 `io::Error`。
pub fn open_slcan(device: &str, options: SlcanOptions) -> Result<CanLink> {
dji_esc_slcan::open_link_with(device, options)
}
/// 打开 SocketCAN 接口(Linux)。
///
/// # Errors
///
/// 接口不存在 / 未 up / 权限不足时的 `io::Error`;非 Linux 平台恒为错误。
#[cfg(target_os = "linux")]
pub fn open_socketcan(interface: &str) -> Result<CanLink> {
dji_esc_socketcan::open_link(interface)
}
/// 非 Linux 平台没有 SocketCAN。
///
/// # Errors
///
/// 恒返回 `Unsupported`。
#[cfg(not(target_os = "linux"))]
pub fn open_socketcan(interface: &str) -> Result<CanLink> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!("SocketCAN ({interface}) 仅支持 Linux"),
))
}
/// 默认 slcan 参数(1 Mbps、串口超时 50 ms)。
#[must_use]
pub fn default_slcan_options(serial_baud: u32, speed: CanSpeed) -> SlcanOptions {
SlcanOptions { serial_baud, speed, read_timeout: Duration::from_millis(50) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn socketcan_is_unavailable_off_linux() {
#[cfg(not(target_os = "linux"))]
{
let err = open_socketcan("can0").expect_err("非 Linux 必须报错");
assert_eq!(err.kind(), std::io::ErrorKind::Unsupported);
}
#[cfg(target_os = "linux")]
{
// Linux 上接口名不存在时也必须是 Err,而不是 panic。
let _ = open_socketcan("definitely-not-a-can-interface");
}
}
#[test]
fn slcan_failure_is_an_error_not_a_panic() {
let result =
open_slcan("/definitely/not/a/device", default_slcan_options(2_000_000, CanSpeed::M1));
assert!(result.is_err());
}
#[test]
fn default_options_carry_the_requested_baud() {
let options = default_slcan_options(115_200, CanSpeed::K500);
assert_eq!(options.serial_baud, 115_200);
assert_eq!(options.speed, CanSpeed::K500);
assert_eq!(options.read_timeout, Duration::from_millis(50));
}
}
-322
View File
@@ -1,322 +0,0 @@
//! 台架安全包络:把"什么时候必须立刻停机"变成可复用的判定。
//!
//! 台架工具(`chassis-spin`)与自动整定的真机后端(`chassis-tune --backend bench`)
//! 共用同一套判定,避免安全逻辑在两个地方各写一遍、然后慢慢漂移。
//!
//! 所有阈值都是 `Option`:只开需要的那个。`chassis-spin` 只开过速保护
//! (保持历史行为),自动整定则开启全部——因为搜索会主动试错,必须由
//! 包络兜住"试错到失控"。
use std::time::Duration;
use motor_core::MotorFeedback;
// 用 tokio 的时钟:`start_paused` 下虚拟时间可控,安全包络(宽限期 / 超时)
// 才能被确定性地测试;生产构建里它就是 `std::time::Instant`。
use thiserror::Error;
use tokio::time::Instant;
/// 触发停机的原因。
#[derive(Debug, Clone, PartialEq, Error)]
pub enum Abort {
/// 转速超过上限并持续超过宽限期。
#[error("overspeed: {peak_rpm:.0} rpm > {limit_rpm:.0} rpm for over {grace_ms}ms")]
Overspeed {
/// 观测到的峰值转速(rpm)。
peak_rpm: f32,
/// 配置的上限(rpm)。
limit_rpm: f32,
/// 宽限期(毫秒)。
grace_ms: u64,
},
/// 温度超过上限。
#[error("overtemperature: {temperature_c}℃ > {limit_c}℃")]
OverTemperature {
/// 观测温度(℃)。
temperature_c: i8,
/// 上限(℃)。
limit_c: i8,
},
/// 反馈超出新鲜度阈值(掉线)。
#[error("esc {esc_id} feedback lost: age {age_ms:?}ms > {timeout_ms}ms")]
FeedbackLost {
/// 电调 ID。
esc_id: u8,
/// 实测反馈年龄(毫秒)。
age_ms: Option<u128>,
/// 阈值(毫秒)。
timeout_ms: u64,
},
/// 整个试验超时。
#[error("trial timeout after {elapsed_ms}ms")]
Timeout {
/// 已耗时(毫秒)。
elapsed_ms: u128,
},
/// 上层主动取消(ctrl-c)。
#[error("cancelled by operator")]
Cancelled,
}
/// 安全包络(阈值 + 宽限期)。
#[derive(Debug, Clone, PartialEq)]
pub struct SafetyEnvelope {
/// 转速上限(rpm)。
pub max_rpm: Option<f32>,
/// 温度上限(℃)。
pub max_temperature_c: Option<i8>,
/// 反馈新鲜度阈值:超过它即视为掉线。
pub feedback_timeout: Option<Duration>,
/// 过速需要持续多久才判定为失控(抑制单帧噪声)。
pub overspeed_grace: Duration,
/// 单次试验的总时长上限。
pub trial_timeout: Option<Duration>,
}
impl Default for SafetyEnvelope {
fn default() -> Self {
Self {
max_rpm: None,
max_temperature_c: None,
feedback_timeout: None,
overspeed_grace: Duration::from_millis(60),
trial_timeout: None,
}
}
}
impl SafetyEnvelope {
/// 空包络(不判定任何条件)。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 只开过速保护(`chassis-spin` 的历史行为)。
#[must_use]
pub fn overspeed_only(max_rpm: f32, grace: Duration) -> Self {
Self { max_rpm: Some(max_rpm), overspeed_grace: grace, ..Self::default() }
}
/// 开过速保护。
#[must_use]
pub fn with_overspeed(mut self, max_rpm: f32, grace: Duration) -> Self {
self.max_rpm = Some(max_rpm);
self.overspeed_grace = grace;
self
}
/// 开温度保护。
#[must_use]
pub fn with_temperature_max(mut self, max_temperature_c: i8) -> Self {
self.max_temperature_c = Some(max_temperature_c);
self
}
/// 开掉线保护。
#[must_use]
pub fn with_feedback_timeout(mut self, timeout: Duration) -> Self {
self.feedback_timeout = Some(timeout);
self
}
/// 开试验总时长保护。
#[must_use]
pub fn with_trial_timeout(mut self, timeout: Duration) -> Self {
self.trial_timeout = Some(timeout);
self
}
/// 是否需要任何判定(空包络时调用方可以跳过整段检查)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.max_rpm.is_none()
&& self.max_temperature_c.is_none()
&& self.feedback_timeout.is_none()
&& self.trial_timeout.is_none()
}
}
/// 带状态的安全监视器(过速宽限与总时长需要记忆)。
#[derive(Debug, Clone)]
pub struct SafetyMonitor {
envelope: SafetyEnvelope,
started: Instant,
overspeed_since: Option<Instant>,
}
impl SafetyMonitor {
/// 创建监视器并记录起点。
#[must_use]
pub fn new(envelope: SafetyEnvelope, now: Instant) -> Self {
Self { envelope, started: now, overspeed_since: None }
}
/// 当前包络。
#[must_use]
pub const fn envelope(&self) -> &SafetyEnvelope {
&self.envelope
}
/// 检查一组电机在当前时刻是否触碰包络。
///
/// 返回 `Some(Abort)` 即调用方**必须立刻停电流并结束本次试验**。
/// 每次调用**不分配**(1 kHz 台架循环里每拍都要过一遍)。
pub fn check<M: MotorFeedback>(&mut self, motors: &[M], now: Instant) -> Option<Abort> {
if let Some(timeout) = self.envelope.trial_timeout {
let elapsed = now.saturating_duration_since(self.started);
if elapsed > timeout {
return Some(Abort::Timeout { elapsed_ms: elapsed.as_millis() });
}
}
if let Some(timeout) = self.envelope.feedback_timeout {
for motor in motors {
let age = motor.feedback_age();
if age.is_none_or(|age| age > timeout) {
return Some(Abort::FeedbackLost {
esc_id: motor.id(),
age_ms: age.map(|age| age.as_millis()),
timeout_ms: timeout.as_millis() as u64,
});
}
}
}
if let Some(limit) = self.envelope.max_temperature_c {
for motor in motors {
let temperature = motor.state().temperature_c;
if temperature > limit {
return Some(Abort::OverTemperature {
temperature_c: temperature,
limit_c: limit,
});
}
}
}
if let Some(limit) = self.envelope.max_rpm {
let peak = motors
.iter()
.map(|motor| motor.state().rotor_speed_rpm.abs())
.fold(0.0f32, f32::max);
if peak > limit {
match self.overspeed_since {
None => self.overspeed_since = Some(now),
Some(since)
if now.saturating_duration_since(since)
>= self.envelope.overspeed_grace =>
{
return Some(Abort::Overspeed {
peak_rpm: peak,
limit_rpm: limit,
grace_ms: self.envelope.overspeed_grace.as_millis() as u64,
});
}
Some(_) => {}
}
} else {
self.overspeed_since = None;
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use motor_core::{MockMotor, MotorState};
fn motor_with(id: u8, rpm: f32, temperature_c: i8, age: Option<Duration>) -> MockMotor {
let motor = MockMotor::new(id);
let state = MotorState {
rotor_speed_rpm: rpm,
temperature_c,
feedback_count: 1,
..MotorState::default()
};
match age {
Some(age) => motor.inject_state_with_age(state, age),
None => motor.inject_state(state),
}
motor
}
#[test]
fn empty_envelope_never_aborts() {
let motors = vec![motor_with(1, 1.0e6, 120, Some(Duration::from_secs(5)))];
let mut monitor = SafetyMonitor::new(SafetyEnvelope::new(), Instant::now());
assert!(monitor.envelope().is_empty());
assert_eq!(monitor.check(&motors, Instant::now()), None);
}
#[test]
fn overspeed_needs_the_grace_period() {
let motors = vec![motor_with(1, 5000.0, 30, None)];
let grace = Duration::from_millis(60);
let mut monitor =
SafetyMonitor::new(SafetyEnvelope::overspeed_only(3000.0, grace), Instant::now());
let now = Instant::now();
assert_eq!(monitor.check(&motors, now), None, "宽限期内不得触发");
assert_eq!(monitor.check(&motors, now + Duration::from_millis(30)), None);
let abort = monitor.check(&motors, now + grace);
assert!(matches!(abort, Some(Abort::Overspeed { .. })), "{abort:?}");
}
#[test]
fn overspeed_timer_resets_when_back_under_limit() {
let motors = vec![motor_with(1, 5000.0, 30, None)];
let grace = Duration::from_millis(60);
let mut monitor =
SafetyMonitor::new(SafetyEnvelope::overspeed_only(3000.0, grace), Instant::now());
let now = Instant::now();
assert_eq!(monitor.check(&motors, now), None);
let calm = vec![motor_with(1, 100.0, 30, None)];
assert_eq!(monitor.check(&calm, now + Duration::from_millis(30)), None);
// 重新超速:计时必须从头开始。
assert_eq!(monitor.check(&motors, now + Duration::from_millis(40)), None);
assert!(monitor.check(&motors, now + Duration::from_millis(90)).is_none());
assert!(monitor.check(&motors, now + Duration::from_millis(110)).is_some());
}
#[test]
fn temperature_and_feedback_loss_abort_immediately() {
let envelope = SafetyEnvelope::new()
.with_temperature_max(70)
.with_feedback_timeout(Duration::from_millis(100));
let hot = vec![motor_with(1, 0.0, 80, None)];
let mut monitor = SafetyMonitor::new(envelope.clone(), Instant::now());
assert!(matches!(monitor.check(&hot, Instant::now()), Some(Abort::OverTemperature { .. })));
let stale = vec![motor_with(1, 0.0, 30, Some(Duration::from_millis(250)))];
let mut monitor = SafetyMonitor::new(envelope, Instant::now());
assert!(matches!(
monitor.check(&stale, Instant::now()),
Some(Abort::FeedbackLost { esc_id: 1, .. })
));
}
#[test]
fn motor_without_any_feedback_is_lost() {
let envelope = SafetyEnvelope::new().with_feedback_timeout(Duration::from_millis(100));
// 从未收到过反馈:age = None。
let silent = vec![MockMotor::new(3)];
let mut monitor = SafetyMonitor::new(envelope, Instant::now());
assert!(matches!(
monitor.check(&silent, Instant::now()),
Some(Abort::FeedbackLost { esc_id: 3, age_ms: None, .. })
));
}
#[test]
fn trial_timeout_aborts() {
let envelope = SafetyEnvelope::new().with_trial_timeout(Duration::from_millis(500));
let motors = vec![motor_with(1, 0.0, 30, None)];
let start = Instant::now();
let mut monitor = SafetyMonitor::new(envelope, start);
assert_eq!(monitor.check(&motors, start + Duration::from_millis(100)), None);
let abort = monitor.check(&motors, start + Duration::from_millis(600));
assert!(matches!(abort, Some(Abort::Timeout { .. })), "{abort:?}");
}
}
-239
View File
@@ -1,239 +0,0 @@
//! 真机试验:把一条 [`Scenario`] 在真实电调上重放并采集成 [`Trace`]。
//!
//! 与 `dji-esc-sim` 的离线 harness 是**同一套场景词汇**:整定工具先用离线
//! harness 搜索,再用本模块在台架上复核,两条路径的指标口径因此一致。
//!
//! # 安全契约
//!
//! - 每个采样拍都重发设定值——**命令是租约**,不重发会在 50ms 后失电;
//! - 每个采样拍都过一遍 [`SafetyMonitor`];
//! - 无论正常结束、触发包络还是被取消,退出前一律 `disable()`(0 电流)。
use std::future::Future;
use std::time::Duration;
use dji_esc_sim::{Scenario, Trace};
use motor_core::{Disable, MotorFeedback, VelocityDrive};
use tokio::time::{Instant, MissedTickBehavior, interval};
use crate::safety::{Abort, SafetyEnvelope, SafetyMonitor};
/// 一次真机试验的结果。
#[derive(Debug, Clone)]
pub struct TrialOutcome {
/// 采集到的轨迹(触发中止时只含中止前的采样)。
pub trace: Trace,
/// 触发安全包络 / 被取消的原因;`None` 表示按场景跑完。
pub abort: Option<Abort>,
}
impl TrialOutcome {
/// 是否完整跑完场景(没有被安全包络打断)。
#[must_use]
pub fn completed(&self) -> bool {
self.abort.is_none()
}
}
/// 采样节拍下限(防止 `dt_s` 给出病态值把 CPU 打满)。
const MIN_TICK: Duration = Duration::from_micros(50);
/// 在单台电机上重放一条场景。
///
/// - `effort_limit_raw`:本次试运行的 effort 限幅,仅用于标注 `saturated`
/// (控制器真正的钳位在驱动层按 `EscConfig::current_limit` 完成);
/// - `lease_period`:命令租约的续期节拍(比 `scenario.dt_s` 更稀时以采样节拍为准);
/// - `cancel`:外部取消源(ctrl-c / 搜索预算耗尽)。
pub async fn run_step_trial<M, F>(
motor: &M,
scenario: &Scenario,
envelope: &SafetyEnvelope,
effort_limit_raw: i16,
lease_period: Duration,
cancel: F,
) -> TrialOutcome
where
M: VelocityDrive + MotorFeedback + Disable,
F: Future<Output = ()>,
{
let dt = Duration::from_secs_f32(scenario.dt_s.max(1.0e-6)).max(MIN_TICK);
let total = Duration::from_secs_f32(scenario.total_s().max(0.0));
// 续租节拍不得稀于采样节拍,否则会短暂失电。
let lease_period = lease_period.min(dt);
let started = Instant::now();
let mut monitor = SafetyMonitor::new(envelope.clone(), started);
let mut ticker = interval(dt);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut lease = interval(lease_period);
lease.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut trace = Trace::with_capacity(scenario.samples());
let mut setpoint = scenario.setpoint_at(0.0);
let mut abort = None;
tokio::pin!(cancel);
loop {
tokio::select! {
() = &mut cancel => {
abort = Some(Abort::Cancelled);
break;
}
_ = lease.tick() => {
// 续租:命令是租约,不重发就失电。
motor.set_velocity(setpoint);
}
_ = ticker.tick() => {
let now = Instant::now();
let elapsed = now.saturating_duration_since(started);
if elapsed > total {
break;
}
let t_s = elapsed.as_secs_f32();
setpoint = scenario.setpoint_at(t_s);
motor.set_velocity(setpoint);
let state = motor.state();
let saturated =
i32::from(state.effort_raw).abs() >= i32::from(effort_limit_raw);
trace.push(t_s, setpoint, state.rotor_speed_rpm, state.effort_raw, saturated);
if let Some(reason) = monitor.check(std::slice::from_ref(motor), now) {
abort = Some(reason);
break;
}
}
}
}
// 安全收尾:任何退出路径都先归零,绝不把非零给定留在总线上。
motor.disable();
TrialOutcome { trace, abort }
}
/// 以 ctrl-c 作为取消源的 [`run_step_trial`] 包装。
pub async fn run_step_trial_until_ctrl_c<M>(
motor: &M,
scenario: &Scenario,
envelope: &SafetyEnvelope,
effort_limit_raw: i16,
lease_period: Duration,
) -> TrialOutcome
where
M: VelocityDrive + MotorFeedback + Disable,
{
run_step_trial(motor, scenario, envelope, effort_limit_raw, lease_period, async {
let _ = tokio::signal::ctrl_c().await;
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
use motor_core::{Command, MockMotor, MotorState};
fn ready_motor(id: u8) -> MockMotor {
let motor = MockMotor::new(id);
motor.inject_state(MotorState { feedback_count: 1, ..MotorState::default() });
motor
}
#[tokio::test(start_paused = true)]
async fn trial_follows_scenario_and_disables_at_the_end() {
let motor = ready_motor(1);
let scenario = Scenario::ramp_and_hold(1000.0, 0.1, 0.2);
let outcome = run_step_trial(
&motor,
&scenario,
&SafetyEnvelope::new(),
12_000,
Duration::from_millis(5),
std::future::pending::<()>(),
)
.await;
assert!(outcome.completed(), "{:?}", outcome.abort);
assert!(outcome.trace.is_consistent());
assert!(outcome.trace.len() > 250, "len = {}", outcome.trace.len());
// 斜坡中点的设定值应约为一半。
let mid = outcome.trace.window(0.049, 0.052).setpoint_rpm()[0];
assert!((mid - 500.0).abs() < 30.0, "mid setpoint = {mid}");
// 收尾必须归零。
assert_eq!(motor.last_command(), Command::Disabled);
}
#[tokio::test(start_paused = true)]
async fn overspeed_aborts_and_zeroes_output() {
let motor = ready_motor(1);
// 反馈恒为 5000 rpm,远超 3000 rpm 上限。
motor.inject_state(MotorState {
rotor_speed_rpm: 5000.0,
feedback_count: 1,
..MotorState::default()
});
let envelope = SafetyEnvelope::overspeed_only(3000.0, Duration::from_millis(20));
let scenario = Scenario::step(1000.0, 2.0);
let outcome = run_step_trial(
&motor,
&scenario,
&envelope,
12_000,
Duration::from_millis(5),
std::future::pending::<()>(),
)
.await;
assert!(matches!(outcome.abort, Some(Abort::Overspeed { .. })), "{:?}", outcome.abort);
assert_eq!(motor.last_command(), Command::Disabled);
// 中止得足够早,不该跑满场景。
assert!(outcome.trace.len() < 200, "len = {}", outcome.trace.len());
}
#[tokio::test(start_paused = true)]
async fn stalled_feedback_aborts() {
let motor = MockMotor::new(4);
motor.inject_state_with_age(
MotorState { feedback_count: 1, ..MotorState::default() },
Duration::from_millis(500),
);
let envelope = SafetyEnvelope::new().with_feedback_timeout(Duration::from_millis(100));
let scenario = Scenario::step(500.0, 1.0);
let outcome = run_step_trial(
&motor,
&scenario,
&envelope,
12_000,
Duration::from_millis(5),
std::future::pending::<()>(),
)
.await;
assert!(
matches!(outcome.abort, Some(Abort::FeedbackLost { esc_id: 4, .. })),
"{:?}",
outcome.abort
);
assert_eq!(motor.last_command(), Command::Disabled);
}
#[tokio::test(start_paused = true)]
async fn cancellation_stops_and_zeroes_output() {
let motor = ready_motor(2);
let scenario = Scenario::step(800.0, 5.0);
let outcome = run_step_trial(
&motor,
&scenario,
&SafetyEnvelope::new(),
12_000,
Duration::from_millis(5),
async { tokio::time::sleep(Duration::from_millis(50)).await },
)
.await;
assert_eq!(outcome.abort, Some(Abort::Cancelled));
assert_eq!(motor.last_command(), Command::Disabled);
assert!(!outcome.trace.is_empty());
assert!(outcome.trace.len() < 200, "取消后不得继续采样");
}
}
+7 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "chassis-config" name = "chassis-config"
description = "chassis.toml schema: parsing, semantic validation, and mapping to driver configuration" description = "chassis.toml schema and validation"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
@@ -10,9 +10,13 @@ license.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
dji-esc = { workspace = true } motor-control = { workspace = true }
mecanum = { workspace = true } mecanum = { workspace = true }
motor-core = { workspace = true } esc-bus = { workspace = true }
motor-protocol = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
+3 -6
View File
@@ -10,9 +10,10 @@
use std::time::Duration; use std::time::Duration;
use dji_esc::{BusOptions, EscConfig, EscModel}; use esc_bus::{BusOptions, EscConfig};
use mecanum::Mecanum; use mecanum::Mecanum;
use motor_core::VelocityGains; use motor_control::VelocityGains;
use motor_protocol::EscModel;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
@@ -411,10 +412,6 @@ impl Config {
gear_ratio: m.gear_ratio, gear_ratio: m.gear_ratio,
direction: m.direction, direction: m.direction,
current_limit: m.current_limit, current_limit: m.current_limit,
kp: m.kp,
ki: m.ki,
kd: m.kd,
feedforward: m.feedforward,
}) })
.collect() .collect()
} }
+25 -20
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "chassis-tune" name = "chassis-tune"
description = "Automated PID gain search for the chassis velocity loop: offline sweep/refine plus bench (hardware-in-the-loop) verification" description = "Automated chassis velocity-loop tuning tool"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
@@ -10,32 +10,37 @@ license.workspace = true
workspace = true workspace = true
[features] [features]
default = [] plot = ['dep:plotters']
# 可选:用 plotters 输出 best.svg(默认关闭,避免 CI 依赖系统字体与更长编译)。
plot = ["dep:plotters"]
[dependencies] [dependencies]
can-link = { workspace = true } bench = { workspace = true }
chassis-bench = { workspace = true } can = { workspace = true }
chassis-config = { workspace = true } chassis-config = { workspace = true }
experiment = { workspace = true }
motor = { workspace = true }
esc-bus = { workspace = true }
motor-protocol = { workspace = true }
motor-control = { workspace = true }
motor-model = { workspace = true }
sim-harness = { workspace = true }
tuning = { workspace = true }
socketcan = { workspace = true }
slcan = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
dji-esc = { workspace = true }
dji-esc-sim = { workspace = true }
motor-core = { workspace = true }
argmin = { workspace = true }
argmin-math = { workspace = true }
rand = { workspace = true }
rayon = { workspace = true }
indicatif = { workspace = true }
csv = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
clap = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
clap = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
csv = { workspace = true }
indicatif = { workspace = true }
rayon = { workspace = true }
rand = { workspace = true }
argmin = { workspace = true }
argmin-math = { workspace = true }
anyhow = { workspace = true }
plotters = { workspace = true, optional = true } plotters = { workspace = true, optional = true }
[dev-dependencies] [dev-dependencies]
-173
View File
@@ -1,173 +0,0 @@
//! 评估后端:把"一组增益有多好"这件事抽象出来。
//!
//! - [`SimEvaluator`]:离线 harness + 被控对象集成(域随机化),CPU 密集、可并行;
//! - [`BenchEvaluator`](crate::bench::BenchEvaluator):真机台架(`chassis-bench`),单轮、串行、有安全包络。
//!
//! 搜索器只认识 [`Evaluator`] trait,因此"先仿真粗调、再上台架复验"是同一套
//! 代码换一个后端,而不是两份实现。
use dji_esc_sim::{LoopLimits, PlantEnsemble, Scenario, simulate};
use motor_core::VelocityGains;
use crate::cost::{Objective, Score, Verdict};
pub use crate::cost::Weights;
/// 一次评估的结果。
pub type Evaluated = Score;
/// 被控对象评估后端。
///
/// 必须 `Send + Sync`:粗扫阶段由 `rayon` 并行调用。
pub trait Evaluator: Send + Sync {
/// 评估一组增益。
fn evaluate(&self, gains: &VelocityGains) -> Evaluated;
/// 一行描述(写进报告,便于复现)。
fn describe(&self) -> String;
}
/// 离线评估:在一组被控对象参数上重放同一条场景。
#[derive(Debug, Clone)]
pub struct SimEvaluator {
/// 被控对象集成(域随机化)。
pub ensemble: PlantEnsemble,
/// 评价场景。
pub scenario: Scenario,
/// 限幅。
pub limits: LoopLimits,
/// 硬约束与权重。
pub objective: Objective,
/// 集成聚合权重:0 = 只看均值,1 = 只看最坏成员。
pub robust_weight: f64,
}
impl SimEvaluator {
/// 组装。
#[must_use]
pub fn new(
ensemble: PlantEnsemble,
scenario: Scenario,
limits: LoopLimits,
objective: Objective,
robust_weight: f64,
) -> Self {
Self { ensemble, scenario, limits, objective, robust_weight: robust_weight.clamp(0.0, 1.0) }
}
/// 集成规模。
#[must_use]
pub fn ensemble_size(&self) -> usize {
self.ensemble.len()
}
}
impl Evaluator for SimEvaluator {
fn evaluate(&self, gains: &VelocityGains) -> Evaluated {
let mut costs = Vec::with_capacity(self.ensemble.len());
let mut nominal: Option<Score> = None;
let mut worst_verdict = Verdict::Ok;
for (index, plant) in self.ensemble.iter().enumerate() {
let score = match simulate(*gains, plant, &self.scenario, self.limits) {
Ok(trace) => self.objective.score(&trace),
Err(error) => Score::penalized(Objective::from_error(error)),
};
if index == 0 {
nominal = Some(score);
}
if !score.verdict.is_ok() && worst_verdict.is_ok() {
worst_verdict = score.verdict;
}
costs.push(score.cost);
}
let count = costs.len() as f64;
let mean = costs.iter().sum::<f64>() / count;
let worst = costs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let cost = (1.0 - self.robust_weight) * mean + self.robust_weight * worst;
// 判定取"名义成员优先,其次最差成员":名义模型都没收敛时不必再看抖动。
// 指标始终报名义成员的——报告要展示的是一条可复现的曲线。
let nominal = nominal.unwrap_or_else(|| Score::penalized(Verdict::Rejected));
let verdict = if nominal.verdict.is_ok() { worst_verdict } else { nominal.verdict };
Score { cost, verdict, metrics: nominal.metrics }
}
fn describe(&self) -> String {
format!(
"sim(ensemble={}, robust_weight={:.2}, scenario={:?}, target={} rpm, limit={} raw, {})",
self.ensemble.len(),
self.robust_weight,
self.scenario.profile,
self.scenario.target_rpm,
self.limits.current_limit_raw,
self.objective.weights.describe()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cost::Weights;
use dji_esc_sim::{PlantParams, PlantTolerances};
fn evaluator(ensemble_size: usize, robust_weight: f64) -> SimEvaluator {
let scenario = Scenario::step(1500.0, 2.0);
let objective = Objective::new(Weights::default(), scenario, LoopLimits::gm3508());
let ensemble = PlantEnsemble::randomized(
PlantParams::gm3508_bench(),
PlantTolerances::default(),
ensemble_size,
11,
);
SimEvaluator::new(ensemble, scenario, LoopLimits::gm3508(), objective, robust_weight)
}
#[test]
fn good_gains_beat_bad_gains_offline() {
let evaluator = evaluator(4, 0.5);
let good = evaluator.evaluate(&VelocityGains::new(4.0, 12.0, 0.0, 0.0));
let bad = evaluator.evaluate(&VelocityGains::new(0.01, 0.0, 0.0, 0.0));
assert!(good.cost < bad.cost, "good = {}, bad = {}", good.cost, bad.cost);
assert_eq!(good.verdict, Verdict::Ok, "{good:?}");
assert_eq!(bad.verdict, Verdict::NoSettle, "{bad:?}");
}
#[test]
fn non_finite_gains_are_rejected_without_panicking() {
let evaluator = evaluator(2, 0.0);
let score = evaluator.evaluate(&VelocityGains::new(f32::NAN, 0.0, 0.0, 0.0));
assert_eq!(score.verdict, Verdict::Rejected);
assert!((score.cost - crate::cost::PENALTY).abs() < 1.0);
assert!(score.metrics.is_none());
}
#[test]
fn evaluation_is_deterministic() {
let evaluator = evaluator(4, 0.5);
let gains = VelocityGains::new(3.0, 9.0, 0.1, 0.5);
let a = evaluator.evaluate(&gains);
let b = evaluator.evaluate(&gains);
assert_eq!(a.cost.to_bits(), b.cost.to_bits());
assert_eq!(a.verdict, b.verdict);
}
#[test]
fn robust_weight_shifts_toward_the_worst_member() {
let mean_only = evaluator(6, 0.0);
let worst_only = evaluator(6, 1.0);
let gains = VelocityGains::new(1.0, 6.0, 0.0, 0.0);
let mean = mean_only.evaluate(&gains);
let worst = worst_only.evaluate(&gains);
assert!(worst.cost >= mean.cost, "worst = {}, mean = {}", worst.cost, mean.cost);
}
#[test]
fn describe_mentions_the_configuration() {
let text = evaluator(3, 0.25).describe();
assert!(text.contains("ensemble=3"), "{text}");
assert!(text.contains("robust_weight=0.25"), "{text}");
assert!(text.contains("1500 rpm"), "{text}");
}
}
-383
View File
@@ -1,383 +0,0 @@
//! 真机(HIL)评估后端:在台架上逐次试验一组增益。
//!
//! 与 [`SimEvaluator`](crate::backend::SimEvaluator) 实现同一个 [`Evaluator`],
//! 因此 `sweep` / `validate` 的全部逻辑(粗扫、精修、报告)都不需要改动,
//! 只是"评估一组增益有多好"这件事从浮点积分换成了真的让轮子转。
//!
//! # 安全设计
//!
//! 每次试验都:① 先确认目标电调反馈新鲜;② 逐拍重发设定值(命令租约);
//! ③ 每拍过一遍 [`SafetyEnvelope`];④ 无论正常结束、触发包络还是 ctrl-c,
//! 退出前一律 `disable()`;⑤ 试验之间插入静默期,让轮子真正停下来再开始下一组。
//!
//! 评估是**串行**的(真机只有一条总线)。调用方应把搜索放进单线程 rayon 池
//! (见 `main`),否则大量线程会在互斥锁上排队、白白浪费时间。
use std::sync::Mutex;
use std::time::Duration;
use anyhow::Context as _;
use chassis_bench::{Abort, SafetyEnvelope, run_step_trial};
use dji_esc::{BusOptions, EscBus, EscBusHandle, EscConfig, EscMotor};
use dji_esc_sim::{LoopLimits, Scenario, Trace};
use motor_core::{Disable as _, MotorFeedback, VelocityGains};
use thiserror::Error;
use crate::backend::Evaluator;
use crate::cost::{Objective, Score, Verdict};
/// 真机后端错误。
#[derive(Debug, Error)]
pub enum BenchError {
/// 无法打开总线。
#[error("cannot open CAN link: {0}")]
Link(String),
/// 电调总线启动失败。
#[error("cannot start ESC bus: {0}")]
Bus(String),
/// 启动超时:目标电调没有反馈。
#[error("esc {esc_id} has no fresh feedback after {waited_ms}ms (wiring / power / bitrate?)")]
NoFeedback {
/// 目标电调 ID。
esc_id: u8,
/// 等待时长(毫秒)。
waited_ms: u64,
},
/// 搜索期间因安全事件中止且未要求继续。
#[error("aborted by safety envelope: {reason}")]
Aborted {
/// 触发原因。
reason: String,
},
}
/// 真机评估配置。
#[derive(Debug, Clone)]
pub struct BenchConfig {
/// 电调配置(名称、ID、方向、减速比、限流)——增益由搜索覆写。
pub esc: EscConfig,
/// 总线运行参数。
pub bus: BusOptions,
/// 评估场景。
pub scenario: Scenario,
/// 安全包络。
pub envelope: SafetyEnvelope,
/// 硬约束与权重。
pub objective: Objective,
/// 试验之间的静默期(等轮子停稳)。
pub between_trials: Duration,
/// 命令租约续期节拍。
pub lease_period: Duration,
/// 启动时等待反馈的超时。
pub startup_timeout: Duration,
/// 安全检查触发后是否继续搜索(默认 false = 立即停)。
pub continue_on_abort: bool,
}
impl BenchConfig {
/// 合理默认:1 kHz 总线、50 ms 租约、5 s 启动超时、500 ms 试验间隔。
#[must_use]
pub fn new(
esc: EscConfig,
scenario: Scenario,
envelope: SafetyEnvelope,
objective: Objective,
) -> Self {
Self {
esc,
bus: BusOptions::default(),
scenario,
envelope,
objective,
between_trials: Duration::from_millis(500),
lease_period: Duration::from_millis(5),
startup_timeout: Duration::from_secs(5),
continue_on_abort: false,
}
}
}
/// 真机评估器(持有总线与运行时)。
pub struct BenchEvaluator {
session: Mutex<Session>,
scenario: Scenario,
envelope: SafetyEnvelope,
objective: Objective,
effort_limit_raw: i16,
between_trials: Duration,
lease_period: Duration,
continue_on_abort: bool,
/// 目前见过的最优轨迹(报告用)。
best: Mutex<Option<(f64, Trace)>>,
/// 中止记录(报告用)。
aborts: Mutex<Vec<String>>,
describe: String,
}
struct Session {
runtime: tokio::runtime::Runtime,
bus: Option<EscBusHandle>,
motor: EscMotor,
}
impl BenchEvaluator {
/// 打开链路、启动总线并等待目标电调反馈。
///
/// # Errors
///
/// 链路 / 总线启动失败,或启动超时(电调无反馈)。
pub fn open(link: can_link::CanLink, config: BenchConfig) -> Result<Self, BenchError> {
let esc_id = config.esc.esc_id;
let limits =
LoopLimits { current_limit_raw: config.esc.current_limit, model: config.esc.model };
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.map_err(|e| BenchError::Bus(e.to_string()))?;
let bus = EscBus::spawn(link, vec![config.esc.clone()], config.bus.clone())
.map_err(|e| BenchError::Bus(e.to_string()))?;
let motor = bus
.motor(esc_id)
.ok_or_else(|| BenchError::Bus(format!("esc {esc_id} missing from the bus")))?
.clone();
let startup_timeout = config.startup_timeout;
let ready = runtime.block_on(async {
let deadline = tokio::time::Instant::now() + startup_timeout;
loop {
if motor.feedback_age().is_some_and(|age| age <= Duration::from_millis(100)) {
return true;
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
});
let evaluator = Self {
session: Mutex::new(Session { runtime, bus: Some(bus), motor }),
scenario: config.scenario,
envelope: config.envelope,
objective: config.objective,
effort_limit_raw: limits.current_limit_raw,
between_trials: config.between_trials,
lease_period: config.lease_period,
continue_on_abort: config.continue_on_abort,
best: Mutex::new(None),
aborts: Mutex::new(Vec::new()),
describe: format!(
"bench(esc={esc_id}, name={}, limit={} raw, target={} rpm, trial={:.1}s, gap={}ms)",
config.esc.name,
limits.current_limit_raw,
config.scenario.target_rpm,
config.scenario.total_s(),
config.between_trials.as_millis()
),
};
if !ready {
// 启动失败也必须走完整停机路径:先 0 电流,再关总线。
evaluator.shutdown();
return Err(BenchError::NoFeedback {
esc_id,
waited_ms: startup_timeout.as_millis() as u64,
});
}
Ok(evaluator)
}
/// 最优轨迹(若有)。
#[must_use]
pub fn best_trace(&self) -> Option<Trace> {
self.best
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.map(|(_, trace)| trace.clone())
}
/// 搜索期间的安全中止记录。
#[must_use]
pub fn aborts(&self) -> Vec<String> {
self.aborts.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
}
/// 停机并关闭总线(**必须**在结束前调用)。
pub fn shutdown(&self) {
let mut session = self.session.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(bus) = session.bus.take() else {
return;
};
for motor in bus.motors() {
motor.disable();
}
session.runtime.block_on(async {
tokio::time::sleep(Duration::from_millis(50)).await;
bus.shutdown().await;
});
}
/// 一次真机试验(含试验间隔静默)。
fn run_trial(&self, gains: &VelocityGains) -> Score {
let mut session = self.session.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Session { runtime, motor, .. } = &mut *session;
motor.set_velocity_gains(*gains);
let scenario = self.scenario;
let envelope = self.envelope.clone();
let limit = self.effort_limit_raw;
let lease = self.lease_period;
let between = self.between_trials;
let outcome = runtime.block_on(async {
let trial =
run_step_trial(motor, &scenario, &envelope, limit, lease, std::future::pending())
.await;
// 试验间隔:让轮子真正停下来,再让下一次试验的起点一致。
tokio::time::sleep(between).await;
trial
});
if let Some(reason) = outcome.abort {
let verdict = verdict_for(&reason);
let message = format!("{}: {reason}", self.describe);
self.aborts.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(message);
return Score::penalized(verdict);
}
let score = match crate::metrics::StepMetrics::from_trace(&outcome.trace, &self.scenario) {
Some(metrics) => self.objective.score_metrics(metrics),
None => Score::penalized(Verdict::Rejected),
};
// 记录目前最优的轨迹,供报告画图 / 复盘。
let mut best = self.best.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if best.as_ref().is_none_or(|(cost, _)| score.cost < *cost) {
*best = Some((score.cost, outcome.trace));
}
score
}
}
impl Drop for BenchEvaluator {
fn drop(&mut self) {
self.shutdown();
}
}
impl Evaluator for BenchEvaluator {
fn evaluate(&self, gains: &VelocityGains) -> Score {
let score = self.run_trial(gains);
if !score.verdict.is_ok() && !self.continue_on_abort {
// 真机上出现安全事件时不"带伤继续搜索":交给调用方在 sweep 之后
// 检查 `aborts()` 并决定是否收工。这里只记录,不 panic。
tracing::warn!(verdict = score.verdict.label(), "bench trial did not pass");
}
score
}
fn describe(&self) -> String {
self.describe.clone()
}
}
/// 安全事件 → 成本判定。
///
/// 过速 / 过温说明控制器失稳;掉线 / 超时 / 取消说明本次试验没做完,
/// 两者的结果都不可用,但原因不同——报告要能区分。
#[must_use]
pub fn verdict_for(abort: &Abort) -> Verdict {
match abort {
Abort::Overspeed { .. } | Abort::OverTemperature { .. } => Verdict::Overspeed,
Abort::FeedbackLost { .. } | Abort::Timeout { .. } | Abort::Cancelled => Verdict::Rejected,
}
}
/// 打开真机链路:给了 `interface` 走 SocketCAN,否则走 slcan 串口。
///
/// # Errors
///
/// 设备 / 接口打开失败(路径、权限、接口未 up、平台不支持)时返回错误。
pub fn open_link(
device: &str,
serial_baud: u32,
can_speed: chassis_bench::CanSpeed,
interface: Option<&str>,
) -> Result<can_link::CanLink, BenchError> {
let result = match interface {
Some(interface) => chassis_bench::open_socketcan(interface),
None => chassis_bench::open_slcan(
device,
chassis_bench::default_slcan_options(serial_baud, can_speed),
),
};
result
.with_context(|| match interface {
Some(interface) => format!("打开 SocketCAN 接口 {interface} 失败(ip link / 权限?)"),
None => format!("打开 slcan 设备 {device} 失败(路径 / 权限 / 占用?)"),
})
.map_err(|e| BenchError::Link(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cost::Weights;
fn config() -> BenchConfig {
let mut esc = EscConfig::gm3508("bench", 1, 1.0);
esc.current_limit = 1500;
let scenario = Scenario::ramp_and_hold(500.0, 0.05, 0.15);
let objective = Objective::new(
Weights::default(),
scenario,
LoopLimits { current_limit_raw: esc.current_limit, model: esc.model },
);
let envelope = SafetyEnvelope::new()
.with_overspeed(1200.0, Duration::from_millis(60))
.with_temperature_max(70)
.with_feedback_timeout(Duration::from_millis(200))
.with_trial_timeout(Duration::from_secs(2));
BenchConfig::new(esc, scenario, envelope, objective)
}
#[test]
fn config_defaults_are_safe() {
let config = config();
assert_eq!(config.bus.period, Duration::from_millis(1));
assert_eq!(config.bus.command_timeout, Duration::from_millis(50));
assert!(!config.continue_on_abort, "默认必须在安全事件后收工");
assert!(config.between_trials >= Duration::from_millis(200));
assert_eq!(config.lease_period, Duration::from_millis(5), "续租必须远密于 50ms 租约");
}
#[test]
fn every_abort_maps_to_a_definite_verdict() {
let cases = [
Abort::Overspeed { peak_rpm: 5000.0, limit_rpm: 1200.0, grace_ms: 60 },
Abort::OverTemperature { temperature_c: 90, limit_c: 70 },
Abort::FeedbackLost { esc_id: 1, age_ms: None, timeout_ms: 200 },
Abort::Timeout { elapsed_ms: 6000 },
Abort::Cancelled,
];
for abort in cases {
let verdict = verdict_for(&abort);
assert!(!verdict.is_ok(), "{abort:?} 不得被判为达标");
}
assert_eq!(
verdict_for(&Abort::Overspeed { peak_rpm: 5000.0, limit_rpm: 1200.0, grace_ms: 60 }),
Verdict::Overspeed
);
assert_eq!(verdict_for(&Abort::Cancelled), Verdict::Rejected);
}
#[test]
fn link_open_failures_are_errors() {
// 不存在的设备:必须返回 Err 而不是 panic(真机前最常见的失败模式)。
let result =
open_link("/definitely/not/a/device", 2_000_000, chassis_bench::CanSpeed::M1, None);
assert!(result.is_err(), "不存在的串口设备必须报错");
let missing_interface =
open_link("ignored", 2_000_000, chassis_bench::CanSpeed::M1, Some("can-not-real"));
assert!(missing_interface.is_err(), "不存在的 CAN 接口必须报错");
}
}
-302
View File
@@ -1,302 +0,0 @@
//! 成本函数:把指标折成一个标量,并把"不可接受"变成硬约束。
//!
//! 两个原则:
//!
//! 1. **硬约束不用权重表达**。过速 / 长时间饱和 / 不收敛不是"差一点",
//! 而是"不能用"——它们直接给罚值并记录判定,绝不允许被其他项的权重
//! 抵消掉(否则搜索会主动挑一个会飞车的解)。
//! 2. **各项归一化**。时间按轨迹时长归一、误差按 `|目标| × 时长` 归一、
//! effort 按限幅归一,因此权重在不同场景 / 不同电机之间仍然可解释。
use dji_esc_sim::{LoopLimits, Scenario, SimError, Trace};
use crate::metrics::StepMetrics;
/// 罚值:远大于任何合法成本,但保持有限(避免 NaN 传染给优化器)。
pub const PENALTY: f64 = 1.0e9;
/// 成本权重(各项已归一化到 O(1))。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Weights {
/// 调节时间权重。
pub settle: f64,
/// 超调权重。
pub overshoot: f64,
/// 绝对误差积分(IAE)权重。
pub iae: f64,
/// effort(电流)权重。
pub effort: f64,
}
impl Default for Weights {
fn default() -> Self {
// 底盘速度环:跟得上、不过冲最重要;电流只作为次要惩罚
// (省电流不是目的,避免烧电调才是)。
Self { settle: 1.0, overshoot: 1.0, iae: 0.5, effort: 0.1 }
}
}
impl Weights {
/// 人类可读描述(日志 / 报告用)。
#[must_use]
pub fn describe(&self) -> String {
format!(
"cost={:.2}·settle+{:.2}·overshoot+{:.2}·iae+{:.2}·effort",
self.settle, self.overshoot, self.iae, self.effort
)
}
/// 权重是否全部有限且非负。
#[must_use]
pub fn is_valid(&self) -> bool {
[self.settle, self.overshoot, self.iae, self.effort]
.iter()
.all(|w| w.is_finite() && *w >= 0.0)
}
}
/// 一次评估的判定。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
/// 达标(成本 = 加权指标)。
Ok,
/// 增益含 NaN / inf,或场景退化(被拒绝,不进控制器)。
Rejected,
/// 数值发散。
Diverged,
/// 过速。
Overspeed,
/// 饱和占比过高(积分绕死 / 限幅太紧)。
Saturated,
/// 在场景内没有收敛。
NoSettle,
}
impl Verdict {
/// 是否达标。
#[must_use]
pub fn is_ok(self) -> bool {
matches!(self, Self::Ok)
}
/// 简短标签(报告 / CSV 用)。
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Rejected => "rejected",
Self::Diverged => "diverged",
Self::Overspeed => "overspeed",
Self::Saturated => "saturated",
Self::NoSettle => "no-settle",
}
}
}
/// 一次评估的结果。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Score {
/// 成本(越小越好)。
pub cost: f64,
/// 判定。
pub verdict: Verdict,
/// 指标(被拒绝时为 `None`)。
pub metrics: Option<StepMetrics>,
}
impl Score {
/// 罚分结果。
#[must_use]
pub fn penalized(verdict: Verdict) -> Self {
Self { cost: PENALTY, verdict, metrics: None }
}
}
/// 目标函数:场景 + 限幅 + 权重 + 硬约束。
#[derive(Debug, Clone, PartialEq)]
pub struct Objective {
/// 权重。
pub weights: Weights,
/// 评价场景。
pub scenario: Scenario,
/// 限幅与型号。
pub limits: LoopLimits,
/// 过速硬上限(rpm)。
pub max_rpm: f32,
/// 允许的饱和占比上限(0..1)。
pub max_saturated_fraction: f32,
}
impl Objective {
/// 默认硬约束:过速上限 = 目标 × 3 + 500;饱和占比 ≤ 40%。
#[must_use]
pub fn new(weights: Weights, scenario: Scenario, limits: LoopLimits) -> Self {
let max_rpm = scenario.target_rpm.abs() * 3.0 + 500.0;
Self { weights, scenario, limits, max_rpm, max_saturated_fraction: 0.4 }
}
/// 由仿真错误折算判定。
#[must_use]
pub fn from_error(error: SimError) -> Verdict {
match error {
SimError::NonFiniteGains | SimError::DegenerateScenario => Verdict::Rejected,
SimError::Diverged { .. } => Verdict::Diverged,
}
}
/// 对一条轨迹评分。
#[must_use]
pub fn score(&self, trace: &Trace) -> Score {
let Some(metrics) = StepMetrics::from_trace(trace, &self.scenario) else {
return Score::penalized(Verdict::Rejected);
};
self.score_metrics(metrics)
}
/// 对一组指标评分(真机后端直接复用,保证两条路径同一把尺子)。
#[must_use]
pub fn score_metrics(&self, metrics: StepMetrics) -> Score {
if metrics.max_rpm > self.max_rpm {
return Score { cost: PENALTY, verdict: Verdict::Overspeed, metrics: Some(metrics) };
}
if metrics.saturated_pct / 100.0 > self.max_saturated_fraction {
return Score { cost: PENALTY, verdict: Verdict::Saturated, metrics: Some(metrics) };
}
if !metrics.settled {
return Score { cost: PENALTY, verdict: Verdict::NoSettle, metrics: Some(metrics) };
}
Score { cost: self.weighted_cost(&metrics), verdict: Verdict::Ok, metrics: Some(metrics) }
}
/// 归一化加权成本。
#[must_use]
pub fn weighted_cost(&self, metrics: &StepMetrics) -> f64 {
let total_s = f64::from(self.scenario.total_s().max(1.0e-3));
let target = f64::from(self.scenario.target_rpm.abs()).max(1.0e-3);
let limit = f64::from(self.limits.current_limit_raw.max(1));
let settle = f64::from(metrics.settling_time_s) / total_s;
let overshoot = f64::from(metrics.overshoot_pct) / 100.0;
let iae = f64::from(metrics.iae) / (target * total_s);
let effort = f64::from(metrics.rms_effort_raw) / limit;
self.weights.settle * settle
+ self.weights.overshoot * overshoot
+ self.weights.iae * iae
+ self.weights.effort * effort
}
/// 稳态误差是否满足验收阈值(报告 / 退出码用)。
#[must_use]
pub fn passes(
&self,
metrics: &StepMetrics,
max_overshoot_pct: f32,
max_ss_error_pct: f32,
) -> bool {
metrics.settled
&& metrics.overshoot_pct <= max_overshoot_pct
&& metrics.ss_error_pct <= max_ss_error_pct
}
}
#[cfg(test)]
mod tests {
use super::*;
use dji_esc_sim::{PlantParams, simulate};
use motor_core::VelocityGains;
const GOOD: VelocityGains = VelocityGains::new(8.0, 2.0, 0.0, 16384.0 / 9254.0);
fn objective(scenario: Scenario) -> Objective {
Objective::new(Weights::default(), scenario, LoopLimits::gm3508())
}
#[test]
fn good_gains_produce_a_finite_ok_cost() {
let scenario = Scenario::step(1500.0, 1.0);
let trace =
simulate(GOOD, &PlantParams::gm3508(), &scenario, LoopLimits::gm3508()).unwrap();
let score = objective(scenario).score(&trace);
assert_eq!(score.verdict, Verdict::Ok);
assert!(score.cost.is_finite() && score.cost < 10.0, "cost = {}", score.cost);
assert!(score.metrics.is_some());
}
#[test]
fn non_converging_gains_are_penalized_not_weighted() {
// 纯比例、极低增益:到不了目标。
let scenario = Scenario::step(4000.0, 1.0);
let weak = VelocityGains::new(0.01, 0.0, 0.0, 0.0);
let trace =
simulate(weak, &PlantParams::gm3508(), &scenario, LoopLimits::gm3508()).unwrap();
let score = objective(scenario).score(&trace);
assert_eq!(score.verdict, Verdict::NoSettle);
assert!((score.cost - PENALTY).abs() < 1.0);
}
#[test]
fn saturation_is_a_hard_constraint() {
let scenario = Scenario::step(9000.0, 1.0);
// 增益极大 + 限流很小 → 长时间饱和。
let limits = LoopLimits { current_limit_raw: 800, model: dji_esc::EscModel::C620 };
let trace = simulate(
VelocityGains::new(500.0, 0.0, 0.0, 0.0),
&PlantParams::gm3508(),
&scenario,
limits,
)
.unwrap();
let score = Objective::new(Weights::default(), scenario, limits).score(&trace);
assert!(
matches!(score.verdict, Verdict::Saturated | Verdict::NoSettle),
"{:?}",
score.verdict
);
assert!(score.cost >= PENALTY);
}
#[test]
fn verdict_labels_are_stable() {
assert_eq!(Verdict::Ok.label(), "ok");
assert_eq!(Verdict::NoSettle.label(), "no-settle");
assert!(Verdict::Ok.is_ok());
assert!(!Verdict::Overspeed.is_ok());
}
#[test]
fn weights_must_be_finite_and_non_negative() {
assert!(Weights::default().is_valid());
let bad = Weights { settle: -1.0, ..Weights::default() };
assert!(!bad.is_valid());
let nan = Weights { iae: f64::NAN, ..Weights::default() };
assert!(!nan.is_valid());
}
#[test]
fn weighted_cost_decreases_when_the_response_improves() {
let scenario = Scenario::step(1500.0, 1.0);
let objective = objective(scenario);
let good = simulate(GOOD, &PlantParams::gm3508(), &scenario, LoopLimits::gm3508()).unwrap();
let sluggish = simulate(
VelocityGains::new(0.6, 1.0, 0.0, 0.0),
&PlantParams::gm3508(),
&scenario,
LoopLimits::gm3508(),
)
.unwrap();
let good_metrics = StepMetrics::from_trace(&good, &scenario).unwrap();
let sluggish_metrics = StepMetrics::from_trace(&sluggish, &scenario).unwrap();
assert!(
objective.weighted_cost(&good_metrics) < objective.weighted_cost(&sluggish_metrics),
"好的响应必须成本更低"
);
}
#[test]
fn sim_errors_map_to_verdicts() {
assert_eq!(Objective::from_error(SimError::NonFiniteGains), Verdict::Rejected);
assert_eq!(Objective::from_error(SimError::DegenerateScenario), Verdict::Rejected);
assert_eq!(Objective::from_error(SimError::Diverged { step: 3 }), Verdict::Diverged);
}
}
-572
View File
@@ -1,572 +0,0 @@
//! 增益参数空间:轴定义、采样与**坐标空间**映射。
//!
//! # 为什么要有"坐标空间"
//!
//! 增益的合法取值跨越数量级(`ki` 可能在 0.1 与 100 之间),线性空间的
//! 单纯形步长对数量级不敏感;同时 `kp/ki/kd` 必须为正。把每个轴映射到
//! **坐标空间**(对数轴取 `ln`,线性轴恒等)后:
//!
//! - 无约束优化器不会走出正增益区域;
//! - 步长在不同数量级上等价(±0.1 的坐标步长 = ×1.1 的比例步长);
//! - `LogOrZero` 轴在**粗扫**阶段可以精确取 0(`ff = 0` 是台架常态),
//! 在**精修**阶段退化为对数轴(零是不可导的拐点,本地搜索收益为负)。
use motor_core::VelocityGains;
use rand::Rng;
use rand::seq::SliceRandom as _;
use thiserror::Error;
/// 轴数量:`kp`、`ki`、`kd`、`feedforward`。
pub const AXIS_COUNT: usize = 4;
/// 轴的取值范围映射方式。
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Scale {
/// 线性均匀。
Linear,
/// 对数均匀(要求 `min > 0`)。
Log,
/// 以概率 `p_zero` 取精确 0,否则对数均匀(用于 `ki` / `kd` / `ff`)。
LogOrZero {
/// 取 0 的概率。
p_zero: f64,
},
}
impl Scale {
/// 是否是对数族(`Log` / `LogOrZero`)。
#[must_use]
pub fn is_log(self) -> bool {
matches!(self, Self::Log | Self::LogOrZero { .. })
}
/// 从文本解析(CLI 用)。
///
/// # Errors
///
/// 未知名字时返回 [`GainsError::UnknownScale`]。
pub fn parse(text: &str) -> Result<Self, GainsError> {
match text {
"linear" => Ok(Self::Linear),
"log" => Ok(Self::Log),
"log0" | "log-or-zero" => Ok(Self::LogOrZero { p_zero: 0.25 }),
other => Err(GainsError::UnknownScale(other.to_owned())),
}
}
/// 文本名。
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Linear => "linear",
Self::Log => "log",
Self::LogOrZero { .. } => "log0",
}
}
}
/// 参数空间定义错误。
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum GainsError {
/// 轴区间非法。
#[error("invalid range for {axis}: {reason}")]
InvalidRange {
/// 轴名。
axis: String,
/// 原因。
reason: String,
},
/// 未知的映射方式名。
#[error("unknown scale '{0}' (expected linear|log|log0)")]
UnknownScale(String),
/// 区间文本无法解析(期望 `MIN..MAX`)。
#[error("cannot parse range '{0}' (expected MIN..MAX)")]
BadRange(String),
/// 只给了对数轴 0 下界。
#[error("axis {0} uses a log scale but min must be > 0")]
NonPositiveLogMin(String),
}
/// 单条增益轴。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GainAxis {
/// 轴名(`kp` / `ki` / `kd` / `feedforward`)。
pub name: &'static str,
/// 下界。
pub min: f64,
/// 上界。
pub max: f64,
/// 取值映射。
pub scale: Scale,
}
impl GainAxis {
/// 构造。
#[must_use]
pub const fn new(name: &'static str, min: f64, max: f64, scale: Scale) -> Self {
Self { name, min, max, scale }
}
/// 校验。
///
/// # Errors
///
/// 非有限 / `min > max` / 对数轴下界 ≤ 0 时返回错误。
pub fn validate(&self) -> Result<(), GainsError> {
let axis = self.name.to_owned();
if !(self.min.is_finite() && self.max.is_finite()) {
return Err(GainsError::InvalidRange { axis, reason: "bounds must be finite".into() });
}
if self.min > self.max {
return Err(GainsError::InvalidRange {
axis,
reason: format!("min {} > max {}", self.min, self.max),
});
}
if self.scale.is_log() && self.min <= 0.0 {
return Err(GainsError::NonPositiveLogMin(axis));
}
Ok(())
}
/// 在区间内采样(含 `LogOrZero` 的零分支)。
pub fn sample(&self, rng: &mut impl Rng) -> f64 {
match self.scale {
Scale::Linear => rng.random_range(self.min..=self.max),
Scale::Log => self.log_sample(rng),
Scale::LogOrZero { p_zero } => {
if rng.random_bool(p_zero.clamp(0.0, 1.0)) {
0.0
} else {
self.log_sample(rng)
}
}
}
}
fn log_sample(&self, rng: &mut impl Rng) -> f64 {
let lo = self.min.max(f64::MIN_POSITIVE).ln();
let hi = self.max.ln();
if hi <= lo {
return self.min;
}
rng.random_range(lo..=hi).exp()
}
/// 夹到区间内。
#[must_use]
pub fn clamp(&self, value: f64) -> f64 {
let clamped = value.clamp(self.min, self.max);
if clamped.is_finite() { clamped } else { self.min }
}
/// 参数 → 坐标。
#[must_use]
pub fn encode(&self, value: f64) -> f64 {
if self.scale.is_log() {
self.clamp(value).max(f64::MIN_POSITIVE).ln()
} else {
self.clamp(value)
}
}
/// 坐标 → 参数(先夹到坐标边界,再取指数)。
#[must_use]
pub fn decode(&self, coord: f64) -> f64 {
let (lo, hi) = self.coord_bounds();
let coord = if coord.is_finite() { coord.clamp(lo, hi) } else { lo };
if self.scale.is_log() { coord.exp() } else { coord }
}
/// 坐标空间的边界。
#[must_use]
pub fn coord_bounds(&self) -> (f64, f64) {
if self.scale.is_log() {
(self.min.max(f64::MIN_POSITIVE).ln(), self.max.ln())
} else {
(self.min, self.max)
}
}
/// 人类可读描述。
#[must_use]
pub fn describe(&self) -> String {
format!("{} ∈ [{}..{}] ({})", self.name, self.min, self.max, self.scale.name())
}
}
/// 四轴参数空间。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GainSpace {
/// 比例增益(raw / rpm)。
pub kp: GainAxis,
/// 积分增益(raw / (rpm·s))。
pub ki: GainAxis,
/// 微分增益。
pub kd: GainAxis,
/// 速度前馈(raw / rpm)。
pub feedforward: GainAxis,
}
impl GainSpace {
/// 组装。
#[must_use]
pub const fn new(kp: GainAxis, ki: GainAxis, kd: GainAxis, feedforward: GainAxis) -> Self {
Self { kp, ki, kd, feedforward }
}
/// 默认空间:覆盖空载台架(`kp≈2, ki≈10, ff=0`)与带载(`ff≈1.77`)两侧。
#[must_use]
pub const fn default_gm3508() -> Self {
Self {
kp: GainAxis::new("kp", 0.5, 30.0, Scale::Log),
ki: GainAxis::new("ki", 0.1, 100.0, Scale::LogOrZero { p_zero: 0.2 }),
kd: GainAxis::new("kd", 0.01, 3.0, Scale::LogOrZero { p_zero: 0.5 }),
feedforward: GainAxis::new("feedforward", 0.01, 2.5, Scale::LogOrZero { p_zero: 0.2 }),
}
}
/// 校验全部轴。
///
/// # Errors
///
/// 任一轴非法时返回错误。
pub fn validate(&self) -> Result<(), GainsError> {
for axis in self.axes() {
axis.validate()?;
}
Ok(())
}
/// 四轴(顺序固定 `kp, ki, kd, feedforward`)。
#[must_use]
pub const fn axes(&self) -> [GainAxis; AXIS_COUNT] {
[self.kp, self.ki, self.kd, self.feedforward]
}
/// 随机采样一个候选增益。
pub fn sample(&self, rng: &mut impl Rng) -> VelocityGains {
let [kp, ki, kd, ff] = self.axes().map(|axis| axis.sample(rng));
VelocityGains::new(kp as f32, ki as f32, kd as f32, ff as f32)
}
/// 分层(Latin Hypercube)采样:每个轴把 `[0,1)` 等分成 `count` 层,
/// 每层恰好落一个样本,再逐轴独立打乱。
///
/// 相比独立随机采样,分层采样在四维空间里不会留下大片空洞,粗扫阶段
/// 用更少的评估次数覆盖更大的空间。
pub fn stratified(&self, count: usize, rng: &mut impl Rng) -> Vec<VelocityGains> {
let count = count.max(1);
let mut columns: Vec<Vec<f64>> = Vec::with_capacity(AXIS_COUNT);
for axis in self.axes() {
let (lo, hi) = axis.coord_bounds();
let mut column = Vec::with_capacity(count);
for layer in 0..count {
let unit = (layer as f64 + rng.random_range(0.0..1.0)) / count as f64;
column.push(lo + unit * (hi - lo));
}
column.shuffle(rng);
columns.push(column);
}
(0..count)
.map(|index| {
self.from_coords([
columns[0][index],
columns[1][index],
columns[2][index],
columns[3][index],
])
})
.collect()
}
/// 把任意(可能越界 / 非有限)增益夹进空间。
#[must_use]
pub fn clamp_gains(&self, gains: VelocityGains) -> VelocityGains {
self.from_values([gains.kp, gains.ki, gains.kd, gains.feedforward].map(f64::from))
}
/// 由四个参数值构造(先夹界,再保证有限)。
#[must_use]
pub fn from_values(&self, values: [f64; AXIS_COUNT]) -> VelocityGains {
let axes = self.axes();
let v: [f64; AXIS_COUNT] = std::array::from_fn(|i| axes[i].clamp(values[i]));
VelocityGains::new(v[0] as f32, v[1] as f32, v[2] as f32, v[3] as f32)
}
/// 参数 → 坐标。
#[must_use]
pub fn to_coords(&self, gains: &VelocityGains) -> [f64; AXIS_COUNT] {
let axes = self.axes();
let values = [gains.kp, gains.ki, gains.kd, gains.feedforward].map(f64::from);
std::array::from_fn(|i| axes[i].encode(values[i]))
}
/// 坐标 → 参数。
#[must_use]
pub fn from_coords(&self, coords: [f64; AXIS_COUNT]) -> VelocityGains {
let axes = self.axes();
let v: [f64; AXIS_COUNT] = std::array::from_fn(|i| axes[i].decode(coords[i]));
VelocityGains::new(v[0] as f32, v[1] as f32, v[2] as f32, v[3] as f32)
}
/// 坐标空间的边界(下界数组, 上界数组)。
#[must_use]
pub fn coord_bounds(&self) -> ([f64; AXIS_COUNT], [f64; AXIS_COUNT]) {
let axes = self.axes();
let pairs: [(f64, f64); AXIS_COUNT] = std::array::from_fn(|i| axes[i].coord_bounds());
let lower = std::array::from_fn(|i| pairs[i].0);
let upper = std::array::from_fn(|i| pairs[i].1);
(lower, upper)
}
/// 最优解是否贴在某个轴的边界上(提示是否该放宽搜索范围)。
///
/// 贴边界通常意味着"约束而不是数据在决定结果"——继续在这个盒子里搜索
/// 不会更好,报告里要明确说出来,而不是把一个边界值当成最优解交付。
#[must_use]
pub fn boundary_hits(&self, gains: &VelocityGains) -> Vec<String> {
let values = [gains.kp, gains.ki, gains.kd, gains.feedforward];
let mut hits = Vec::new();
for (axis, value) in self.axes().iter().zip(values) {
let value = f64::from(value);
let tolerance = ((axis.max - axis.min).abs() * 0.01).max(1.0e-9);
if (value - axis.max).abs() <= tolerance {
hits.push(format!(
"{} 落在上界 {}(若要继续提升,放宽 --{})",
axis.name, axis.max, axis.name
));
} else if (value - axis.min).abs() <= tolerance {
hits.push(format!(
"{} 落在下界 {}(若要继续压缩,放宽 --{})",
axis.name, axis.min, axis.name
));
}
}
hits
}
/// 人类可读描述(报告用)。
#[must_use]
pub fn describe(&self) -> String {
self.axes().iter().map(GainAxis::describe).collect::<Vec<_>>().join("; ")
}
}
/// 解析 `MIN..MAX`(`..=` 视为等价)。
///
/// # Errors
///
/// 无法解析或区间非法时返回错误。
pub fn parse_axis(name: &'static str, spec: &str, scale: Scale) -> Result<GainAxis, GainsError> {
let spec = spec.trim();
let bad = || GainsError::BadRange(spec.to_owned());
// `MIN..MAX` 与 `MIN..=MAX` 视为等价;单个数值表示固定该轴。
let (min_text, max_text) = match spec.split_once("..=").or_else(|| spec.split_once("..")) {
Some((lo, hi)) => (lo.trim(), hi.trim()),
None => (spec, spec),
};
if min_text.is_empty() || max_text.is_empty() {
return Err(bad());
}
let min: f64 = min_text.parse().map_err(|_| bad())?;
let max: f64 = max_text.parse().map_err(|_| bad())?;
let axis = GainAxis::new(name, min, max, scale);
axis.validate()?;
Ok(axis)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng as _;
use rand::rngs::StdRng;
fn rng(seed: u64) -> StdRng {
StdRng::seed_from_u64(seed)
}
#[test]
fn clamp_keeps_values_inside_the_space() {
let space = GainSpace::default_gm3508();
let wild = VelocityGains::new(1.0e9, -5.0, 100.0, f32::NAN);
let clamped = space.clamp_gains(wild);
assert!(clamped.kp <= 30.0 && clamped.kp >= 0.5);
assert!(clamped.ki >= 0.1);
assert!(clamped.kd <= 3.0);
assert!(clamped.feedforward.is_finite());
assert!(clamped.is_finite());
}
#[test]
fn sampling_stays_inside_bounds_and_is_deterministic() {
let space = GainSpace::default_gm3508();
let mut a = rng(7);
let mut b = rng(7);
for _ in 0..500 {
let g1 = space.sample(&mut a);
let g2 = space.sample(&mut b);
assert_eq!(g1, g2, "同 seed 必须一致");
assert!((0.5..=30.0).contains(&g1.kp));
assert!((0.0..=100.0).contains(&g1.ki));
assert!((0.0..=3.0).contains(&g1.kd));
assert!((0.0..=2.5).contains(&g1.feedforward));
assert!(g1.is_finite());
}
}
#[test]
fn log_axis_samples_across_orders_of_magnitude() {
let axis = GainAxis::new("kp", 0.1, 100.0, Scale::Log);
let mut rng = rng(3);
let mut below_one = 0;
let mut above_ten = 0;
for _ in 0..2000 {
let value = axis.sample(&mut rng);
assert!((0.1..=100.0).contains(&value));
if value < 1.0 {
below_one += 1;
}
if value > 10.0 {
above_ten += 1;
}
}
// 对数均匀:三个数量级应大致各占 1/3。
assert!(below_one > 400 && below_one < 900, "below_one = {below_one}");
assert!(above_ten > 400 && above_ten < 900, "above_ten = {above_ten}");
}
#[test]
fn log_or_zero_hits_exact_zero() {
let axis = GainAxis::new("feedforward", 0.01, 2.5, Scale::LogOrZero { p_zero: 0.5 });
let mut rng = rng(11);
let mut zeros = 0;
for _ in 0..2000 {
if axis.sample(&mut rng) == 0.0 {
zeros += 1;
}
}
assert!((800..1200).contains(&zeros), "zeros = {zeros}");
}
#[test]
fn coord_round_trip_is_stable() {
let space = GainSpace::default_gm3508();
let mut rng = rng(5);
let mut saw_zero = false;
for _ in 0..200 {
let gains = space.sample(&mut rng);
let back = space.from_coords(space.to_coords(&gains));
// 坐标空间对**非零**取值是双射(只允许 f32 舍入误差);
// 0 在对数轴里没有原像,往返会落到该轴下界——这是 LogOrZero 的定义。
for (axis, (sampled, restored)) in space.axes().iter().zip([
(gains.kp, back.kp),
(gains.ki, back.ki),
(gains.kd, back.kd),
(gains.feedforward, back.feedforward),
]) {
if sampled == 0.0 {
saw_zero = true;
assert!(
(restored - axis.min as f32).abs() <= 1e-6 * axis.min as f32,
"{} 的零值往返应落到下界 {},得到 {restored}",
axis.name,
axis.min
);
} else {
let tolerance = 1e-3 * sampled.max(1.0);
assert!(
(restored - sampled).abs() <= tolerance,
"{} 往返误差过大:{sampled} → {restored}",
axis.name
);
}
}
}
assert!(saw_zero, "LogOrZero 轴必须真的采到过 0");
}
#[test]
fn decode_clamps_out_of_range_coordinates() {
let space = GainSpace::default_gm3508();
let below = space.from_coords([-1.0e9, -1.0e9, -1.0e9, -1.0e9]);
assert!(below.kp >= 0.5);
let above = space.from_coords([1.0e9; AXIS_COUNT]);
assert!(above.kp <= 30.0);
assert!(above.feedforward.is_finite());
}
#[test]
fn stratified_covers_every_layer_of_every_axis() {
let space = GainSpace::default_gm3508();
let count = 32;
let samples = space.stratified(count, &mut rng(9));
assert_eq!(samples.len(), count);
// 每个轴在坐标空间被等分:把样本坐标排序后,相邻差应大致等于层宽。
let (lower, upper) = space.coord_bounds();
let span = upper[0] - lower[0];
let mut coords: Vec<f64> = samples.iter().map(|g| space.to_coords(g)[0]).collect();
coords.sort_by(f64::total_cmp);
let layer = span / count as f64;
for window in coords.windows(2) {
let gap = window[1] - window[0];
assert!(gap < layer * 3.0, "gap {gap} too large for layer {layer}");
}
}
#[test]
fn stratified_handles_large_counts_without_truncation() {
let space = GainSpace::default_gm3508();
let samples = space.stratified(96, &mut rng(4));
assert_eq!(samples.len(), 96);
}
#[test]
fn parse_axis_accepts_ranges_and_single_values() {
let axis = parse_axis("kp", "0.5..20", Scale::Log).unwrap();
assert!((axis.min - 0.5).abs() < 1e-12);
assert!((axis.max - 20.0).abs() < 1e-12);
let open = parse_axis("ki", "0..=50", Scale::Linear).unwrap();
assert!((open.max - 50.0).abs() < 1e-12);
let fixed = parse_axis("kd", "0.25", Scale::Linear).unwrap();
assert!((fixed.min - 0.25).abs() < 1e-12 && (fixed.max - 0.25).abs() < 1e-12);
}
#[test]
fn parse_axis_rejects_garbage() {
assert!(matches!(parse_axis("kp", "abc", Scale::Log), Err(GainsError::BadRange(_))));
assert!(matches!(parse_axis("kp", "", Scale::Log), Err(GainsError::BadRange(_))));
assert!(matches!(
parse_axis("kp", "-1..5", Scale::Log),
Err(GainsError::NonPositiveLogMin(_))
));
}
#[test]
fn validate_rejects_inverted_and_non_finite_bounds() {
let bad = GainAxis::new("kp", 10.0, 1.0, Scale::Linear);
assert!(bad.validate().is_err());
let nan = GainAxis::new("kp", f64::NAN, 1.0, Scale::Linear);
assert!(nan.validate().is_err());
}
#[test]
fn validate_accepts_the_default_space() {
assert!(GainSpace::default_gm3508().validate().is_ok());
}
#[test]
fn boundary_hits_flag_pinned_axes_only() {
let space = GainSpace::default_gm3508();
let pinned = VelocityGains::new(30.0, 0.1, 1.0, 1.0);
let hits = space.boundary_hits(&pinned);
assert_eq!(hits.len(), 2, "{hits:?}");
assert!(hits.iter().any(|hit| hit.contains("kp")), "{hits:?}");
assert!(hits.iter().any(|hit| hit.contains("ki")), "{hits:?}");
let inside = VelocityGains::new(5.0, 10.0, 0.5, 1.0);
assert!(space.boundary_hits(&inside).is_empty());
}
}
-60
View File
@@ -1,60 +0,0 @@
//! 底盘速度环 **PID 自动整定**:参数空间搜索 + 指标评估 + 报告。
//!
//! # 一条命令背后发生了什么
//!
//! 1. [`gains`] 定义搜索空间(四轴、对数刻度、可精确取 0 的前馈 / 微分);
//! 2. [`search`] 先做种子化分层粗扫(`rayon` 并行),再以 top-k 为起点做
//! 多起点 `argmin` 局部精修;
//! 3. 每次评估由 [`backend`] 执行:离线 harness 在**被控对象集成**上重放场景,
//! 或(真机后端)在台架上跑一次受安全包络约束的试验;
//! 4. [`metrics`] / [`cost`] 把轨迹折成加权成本,硬约束(过速 / 长时饱和 /
//! 不收敛)直接给罚值,不参与权重折衷;
//! 5. [`report`] 落盘 CSV / TOML / JSON / Markdown(可选 SVG)。
//!
//! # 超参数从哪来
//!
//! "搜什么、怎么打分、什么算达标"由 [`preset`] 定义,优先级为
//! **内置默认 < 预设文件(`config/tune.toml`)< 显式命令行参数**。
//! 一次评审过的预设可以长期复用,不再靠命令行回忆当时那组数。
//!
//! # 可复现
//!
//! 随机性只来自 `--seed`:同一 seed + 同一参数空间 + 同一后端 → 逐位一致的结果。
//! 这是 CI 里做冒烟回归的前提。预设只是把参数空间 / 权重 / 场景固化下来,
//! 因此"同一个预设 + 同一个 seed"在任何机器上都应当复现同一结果。
//!
//! # 安全
//!
//! 真机后端(`--backend bench`)会**真的驱动电机**。安全由三层保证:
//! 搜索前的强制确认标志、每次试验的 `chassis-bench` 安全包络、以及任何退出
//! 路径都先归零电流。
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub mod backend;
pub mod bench;
pub mod cost;
pub mod gains;
pub mod metrics;
#[cfg(feature = "plot")]
pub mod plot;
pub mod preset;
pub mod report;
pub mod search;
pub use backend::{Evaluated, Evaluator, SimEvaluator};
pub use bench::{BenchConfig, BenchError, BenchEvaluator};
pub use cost::{Objective, PENALTY, Score, Verdict, Weights};
pub use gains::{AXIS_COUNT, GainAxis, GainSpace, GainsError, Scale, parse_axis};
pub use metrics::StepMetrics;
pub use preset::{
Acceptance, AcceptanceSpec, BackendSpec, BenchEnvelope, BenchSpec, CostSpec,
DEFAULT_PRESET_PATH, PRESET_VERSION, PlantSpec, Preset, PresetError, PresetFile, ProfileSpec,
RangeSpec, ScaleSpec, ScenarioSpec, SearchSpec, SimSpec, SolverSpec, SpaceSpec, Tuning,
};
pub use report::{ReportContext, ReportError, write_reports};
pub use search::{
Phase, Ranked, SearchConfig, SearchError, SolverKind, Trial, evaluate_once, sweep,
};
/// 被整定的物理量类型(来自 `motor-core`,与驱动层、台架共用同一个定义)。
pub use motor_core::VelocityGains;
File diff suppressed because it is too large Load Diff
-265
View File
@@ -1,265 +0,0 @@
//! 步响应指标:把一条 [`Trace`] 折成可比较、可解释的标量。
//!
//! 指标口径对**所有后端**(离线 harness、真机台架)一致——整定结果能不能
//! 从仿真迁移到实车,取决于两条路径是否用同一把尺子量。
use dji_esc_sim::{Scenario, Trace};
/// 稳态判据的相对带宽(±2% of target)。
pub const SETTLING_BAND: f32 = 0.02;
/// 稳态窗口占轨迹总时长的比例(从 75% 处开始)。
///
/// 与场景自带的 `steady_start_s()` 取**更晚**者:阶跃场景没有显式斜坡,
/// 若不设下限就会把整条上升段也算进"稳态",任何真实响应都会被判成未收敛。
pub const STEADY_FRACTION: f32 = 0.75;
/// 指标最少需要的采样数(样本太少时一切统计都无意义)。
pub const MIN_SAMPLES: usize = 8;
/// 一条步响应轨迹的指标集合。
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct StepMetrics {
/// 上升时间 10% → 90%(秒);未到达时等于轨迹总时长。
pub rise_time_s: f32,
/// 超调量(%,相对目标;负值截为 0)。
pub overshoot_pct: f32,
/// 调节时间:最后一次离开 ±2% 带的时刻(秒)。
pub settling_time_s: f32,
/// 是否在稳态窗口内保持 ±2% 带(即真正收敛)。
pub settled: bool,
/// 稳态误差(%,相对目标)。
pub ss_error_pct: f32,
/// 绝对误差积分 ∫|e|dt(归一化前的原始值)。
pub iae: f32,
/// 误差平方积分 ∫e²dt。
pub ise: f32,
/// 时间加权绝对误差积分 ∫t|e|dt。
pub itae: f32,
/// 峰值 effort(raw,绝对值)。
pub peak_effort_raw: f32,
/// effort 均方根(raw)。
pub rms_effort_raw: f32,
/// effort 符号翻转次数(抖振)。
pub effort_reversals: usize,
/// 饱和采样占比(%)。
pub saturated_pct: f32,
/// 全过程最大转速(rpm,绝对值)。
pub max_rpm: f32,
/// 稳态窗口内的平均转速(rpm)。
pub steady_rpm: f32,
}
impl StepMetrics {
/// 从轨迹计算指标;样本不足或目标转速为 0 时返回 `None`。
#[must_use]
pub fn from_trace(trace: &Trace, scenario: &Scenario) -> Option<Self> {
let target = scenario.target_rpm;
if trace.len() < MIN_SAMPLES || trace.len() != trace.t_s.len() {
return None;
}
if !target.is_finite() || target.abs() < f32::EPSILON {
return None;
}
let sign = if target < 0.0 { -1.0 } else { 1.0 };
let magnitude = target.abs();
// 上升时间:10% / 90% 两个阈值首次被跨过的时刻。
let t10 = trace
.rpm
.iter()
.zip(&trace.t_s)
.find(|(rpm, _)| **rpm * sign >= 0.1 * magnitude)
.map(|(_, &t)| t);
let t90 = trace
.rpm
.iter()
.zip(&trace.t_s)
.find(|(rpm, _)| **rpm * sign >= 0.9 * magnitude)
.map(|(_, &t)| t);
let total_s = trace.t_s.last().copied().unwrap_or(0.0);
let rise_time_s = match (t10, t90) {
(Some(t10), Some(t90)) if t90 >= t10 => t90 - t10,
// 没有跨过 90%:用剩余时长表达"到不了",让成本函数自然惩罚它。
_ => total_s,
};
// 超调:朝着目标方向的过冲。
let peak_directed =
trace.rpm.iter().map(|rpm| *rpm * sign).fold(f32::NEG_INFINITY, f32::max);
let overshoot_pct = ((peak_directed - magnitude) / magnitude * 100.0).max(0.0);
// 稳态窗口:场景给出的起点与"最后 25% 时长"取更晚者。
let steady_start = (total_s * STEADY_FRACTION).max(scenario.steady_start_s());
let steady_end = scenario.steady_end_s().max(steady_start);
let window = trace.window(steady_start, steady_end);
let steady_rpm = window.mean_rpm();
let ss_error_pct = ((steady_rpm - target).abs() / magnitude) * 100.0;
// 调节时间:最后一次离开 ±band 的时刻;此后一直留在带内才算收敛。
let band = SETTLING_BAND * magnitude;
let mut settling_time_s = 0.0f32;
for (rpm, &t) in trace.rpm.iter().zip(&trace.t_s) {
if (*rpm - target).abs() > band {
settling_time_s = t;
}
}
// 收敛 = 最后一次离开带宽的时刻已经进入稳态窗口(此后一直留在带内)。
let settled = settling_time_s <= steady_start && !window.is_empty();
// 误差积分(全过程,按 dt 累加)。
let mut abs_error_area = 0.0f32;
let mut squared_error_area = 0.0f32;
let mut time_weighted_area = 0.0f32;
for index in 1..trace.len() {
let dt = (trace.t_s[index] - trace.t_s[index - 1]).max(0.0);
let error = trace.setpoint_rpm[index] - trace.rpm[index];
abs_error_area += error.abs() * dt;
squared_error_area += error * error * dt;
time_weighted_area += trace.t_s[index] * error.abs() * dt;
}
let peak_effort_raw =
trace.effort_raw.iter().map(|&raw| f32::from(raw).abs()).fold(0.0f32, f32::max);
let rms_effort_raw = if trace.effort_raw.is_empty() {
0.0
} else {
(trace.effort_raw.iter().map(|&raw| f32::from(raw) * f32::from(raw)).sum::<f32>()
/ trace.effort_raw.len() as f32)
.sqrt()
};
Some(Self {
rise_time_s,
overshoot_pct,
settling_time_s,
settled,
ss_error_pct,
iae: abs_error_area,
ise: squared_error_area,
itae: time_weighted_area,
peak_effort_raw,
rms_effort_raw,
effort_reversals: trace.effort_reversals(),
saturated_pct: trace.saturated_fraction() * 100.0,
max_rpm: trace.peak_rpm(),
steady_rpm,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scenario() -> Scenario {
Scenario::step(1000.0, 1.0)
}
/// 合成一条轨迹:一阶响应 + 可选超调 + 可选末端偏差。
fn synthetic(alpha: f32, overshoot: f32, final_offset: f32) -> Trace {
let scenario = scenario();
let dt = scenario.dt_s;
let steps = scenario.samples();
let mut trace = Trace::with_capacity(steps);
let mut rpm = 0.0f32;
for step in 0..steps {
let t = step as f32 * dt;
let target = scenario.setpoint_at(t);
rpm += (target - rpm) * alpha;
let value = if t > 0.3 {
rpm + overshoot * 1000.0 * (-(t - 0.3) * 20.0).exp() + final_offset
} else {
rpm
};
trace.push(t, target, value, 0, false);
}
trace
}
#[test]
fn first_order_response_has_no_overshoot_and_settles() {
let trace = synthetic(0.02, 0.0, 0.0);
let metrics = StepMetrics::from_trace(&trace, &scenario()).unwrap();
assert!(metrics.overshoot_pct < 0.5, "overshoot = {}", metrics.overshoot_pct);
assert!(metrics.settled, "一阶响应必须收敛");
assert!(metrics.rise_time_s > 0.0 && metrics.rise_time_s < 1.0);
assert!(metrics.ss_error_pct < 1.0, "ss = {}", metrics.ss_error_pct);
assert!(metrics.iae > 0.0 && metrics.itae > 0.0);
}
#[test]
fn overshoot_is_measured_against_the_target() {
let trace = synthetic(0.05, 0.2, 0.0);
let metrics = StepMetrics::from_trace(&trace, &scenario()).unwrap();
assert!(
(metrics.overshoot_pct - 20.0).abs() < 3.0,
"overshoot = {}",
metrics.overshoot_pct
);
}
#[test]
fn steady_state_offset_shows_up() {
let trace = synthetic(0.02, 0.0, 50.0);
let metrics = StepMetrics::from_trace(&trace, &scenario()).unwrap();
assert!((metrics.ss_error_pct - 5.0).abs() < 1.0, "ss = {}", metrics.ss_error_pct);
// 5% 的末端偏差超出 ±2% 带 → 不算收敛。
assert!(!metrics.settled);
}
#[test]
fn non_converging_response_is_flagged() {
// 误差始终不减(alpha 很小),且稳态窗口内仍在带外。
let trace = synthetic(0.0005, 0.0, 0.0);
let metrics = StepMetrics::from_trace(&trace, &scenario()).unwrap();
assert!(!metrics.settled, "未收敛必须被标记");
assert!(metrics.ss_error_pct > 2.0);
}
#[test]
fn effort_statistics_and_saturation_are_reported() {
let mut trace = synthetic(0.02, 0.0, 0.0);
for (index, raw) in trace.effort_raw.iter_mut().enumerate() {
*raw = if index % 2 == 0 { 500 } else { -500 };
}
trace.saturated.fill(true);
let metrics = StepMetrics::from_trace(&trace, &scenario()).unwrap();
assert!((metrics.peak_effort_raw - 500.0).abs() < 1e-3);
assert!((metrics.rms_effort_raw - 500.0).abs() < 1e-3);
assert_eq!(metrics.effort_reversals, trace.len() - 1);
assert!((metrics.saturated_pct - 100.0).abs() < 1e-6);
}
#[test]
fn negative_targets_are_handled_symmetrically() {
let scenario = Scenario::step(-1000.0, 1.0);
let steps = scenario.samples();
let mut trace = Trace::with_capacity(steps);
let mut rpm = 0.0f32;
for step in 0..steps {
let t = step as f32 * scenario.dt_s;
let target = scenario.setpoint_at(t);
rpm += (target - rpm) * 0.02;
trace.push(t, target, rpm, 0, false);
}
let metrics = StepMetrics::from_trace(&trace, &scenario).unwrap();
assert!(metrics.settled);
assert!(metrics.ss_error_pct < 1.0);
assert!(metrics.overshoot_pct < 1.0);
}
#[test]
fn degenerate_inputs_are_rejected() {
let short = Trace {
t_s: vec![0.0, 0.001],
setpoint_rpm: vec![1.0, 1.0],
rpm: vec![0.0, 0.1],
effort_raw: vec![0, 0],
saturated: vec![false, false],
};
assert!(StepMetrics::from_trace(&short, &scenario()).is_none());
let zero_target = Scenario::step(0.0, 1.0);
let trace = synthetic(0.02, 0.0, 0.0);
assert!(StepMetrics::from_trace(&trace, &zero_target).is_none());
}
}
-129
View File
@@ -1,129 +0,0 @@
//! 可选(`--features plot`):把最优步响应画成 SVG。
//!
//! 为什么是 SVG 而不是 PNG:矢量图在任何缩放级别都能看清起动段的细节,
//! 且不需要位图编码器(依赖更小)。默认关闭该 feature——绘图不是整定的
//! 必要条件,CSV 已经给了全部数据。
use std::path::Path;
use dji_esc_sim::{Scenario, Trace};
use plotters::backend::SVGBackend;
use plotters::chart::ChartBuilder;
use plotters::drawing::IntoDrawingArea as _;
use plotters::series::LineSeries;
use plotters::style::{BLACK, BLUE, Color as _, RED, WHITE};
use crate::report::ReportError;
/// 写出步响应三联图:转速跟随、误差、电流。
///
/// # Errors
///
/// 绘图或写文件失败时返回 [`ReportError::Plot`]。
pub fn write_step_svg(path: &Path, trace: &Trace, scenario: &Scenario) -> Result<(), ReportError> {
let root = SVGBackend::new(path, (1000, 900)).into_drawing_area();
root.fill(&WHITE).map_err(plot_err)?;
let (upper, lower) = root.split_vertically(600);
let (middle, bottom) = lower.split_vertically(150);
let total = trace.t_s.last().copied().unwrap_or(1.0).max(1.0e-3);
let peak = trace.rpm.iter().copied().fold(scenario.target_rpm, f32::max).max(1.0);
let effort_peak =
trace.effort_raw.iter().map(|&raw| f32::from(raw).abs()).fold(1.0f32, f32::max);
// 转速跟随。
let mut chart = ChartBuilder::on(&upper)
.caption("velocity step response (rpm)", ("sans-serif", 24))
.margin(12)
.x_label_area_size(36)
.y_label_area_size(64)
.build_cartesian_2d(0.0f32..total, 0.0f32..peak * 1.1)
.map_err(plot_err)?;
chart.configure_mesh().x_desc("t (s)").y_desc("rpm").draw().map_err(plot_err)?;
chart
.draw_series(LineSeries::new(
trace.t_s.iter().copied().zip(trace.setpoint_rpm.iter().copied()),
&BLACK,
))
.map_err(plot_err)?
.label("setpoint")
.legend(|(x, y)| plotters::element::PathElement::new([(x, y), (x + 18, y)], BLUE));
chart
.draw_series(LineSeries::new(
trace.t_s.iter().copied().zip(trace.rpm.iter().copied()),
&BLUE,
))
.map_err(plot_err)?
.label("measured")
.legend(|(x, y)| plotters::element::PathElement::new([(x, y), (x + 18, y)], RED));
chart.configure_series_labels().background_style(WHITE.mix(0.8)).draw().map_err(plot_err)?;
// 误差。
let mut error_chart = ChartBuilder::on(&middle)
.caption("error (rpm)", ("sans-serif", 16))
.margin(8)
.x_label_area_size(28)
.y_label_area_size(64)
.build_cartesian_2d(0.0f32..total, -peak..peak)
.map_err(plot_err)?;
error_chart.configure_mesh().draw().map_err(plot_err)?;
error_chart
.draw_series(LineSeries::new(
trace
.t_s
.iter()
.zip(trace.setpoint_rpm.iter().zip(trace.rpm.iter()))
.map(|(&t, (&sp, &rpm))| (t, sp - rpm)),
&RED,
))
.map_err(plot_err)?;
// 电流。
let mut effort_chart = ChartBuilder::on(&bottom)
.caption("effort (raw)", ("sans-serif", 16))
.margin(8)
.x_label_area_size(28)
.y_label_area_size(64)
.build_cartesian_2d(0.0f32..total, -effort_peak..effort_peak)
.map_err(plot_err)?;
effort_chart.configure_mesh().draw().map_err(plot_err)?;
effort_chart
.draw_series(LineSeries::new(
trace.t_s.iter().zip(trace.effort_raw.iter()).map(|(&t, &raw)| (t, f32::from(raw))),
&BLUE,
))
.map_err(plot_err)?;
root.present().map_err(plot_err)?;
Ok(())
}
fn plot_err<E: std::fmt::Debug>(error: E) -> ReportError {
ReportError::Plot(format!("{error:?}"))
}
#[cfg(test)]
mod tests {
use super::*;
use dji_esc_sim::{LoopLimits, PlantParams, SetpointProfile, simulate};
use motor_core::VelocityGains;
#[test]
fn writes_a_non_empty_svg() {
let scenario =
Scenario::ramp_and_hold(1200.0, 0.2, 0.8).with_profile(SetpointProfile::RampAndHold);
let trace = simulate(
VelocityGains::new(4.0, 12.0, 0.0, 0.3),
&PlantParams::gm3508(),
&scenario,
LoopLimits::gm3508(),
)
.unwrap();
let path = std::env::temp_dir().join("chassis-tune-plot.svg");
write_step_svg(&path, &trace, &scenario).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.starts_with("<?xml") || text.contains("<svg"), "not an svg");
assert!(text.len() > 1000, "svg looks empty");
let _ = std::fs::remove_file(&path);
}
}
File diff suppressed because it is too large Load Diff
-713
View File
@@ -1,713 +0,0 @@
//! 报告与产物写出:CSV / TOML / JSON / Markdown(可选 SVG)。
//!
//! 产物布局(`--out-dir`):
//!
//! | 文件 | 内容 |
//! | --- | --- |
//! | `trials.csv` | 全部候选:增益 + 指标 + 成本 + 判定(可用任意表格软件复盘) |
//! | `best.toml` | 可直接粘贴进 `chassis.toml` 的 `[[motor]]` 片段 |
//! | `best-trace.csv` | 最优增益的完整步响应轨迹 |
//! | `report.md` | 人类可读总结:设置、Top-N、结论与注意事项 |
//! | `summary.json` | 机器可读结果(CI / 后续工具消费) |
//! | `best.svg` | 步响应三联图(仅 `--features plot`) |
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use dji_esc_sim::{LoopLimits, Scenario, Trace};
use serde::Serialize;
use crate::gains::GainSpace;
use crate::metrics::StepMetrics;
use crate::search::{Ranked, SearchConfig, Trial, verdict_hint};
/// 报告写出错误。
#[derive(Debug, thiserror::Error)]
pub enum ReportError {
/// 目录创建或文件写入失败。
#[error("io error at {path}: {source}")]
Io {
/// 出错路径。
path: PathBuf,
/// 底层错误。
source: std::io::Error,
},
/// CSV 写出失败。
#[error("csv error: {0}")]
Csv(#[from] csv::Error),
/// JSON 写出失败。
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
/// TOML 写出失败。
#[error("toml error: {0}")]
Toml(#[from] toml::ser::Error),
/// 没有可用的最优结果。
#[error("no feasible trial to report")]
NoBest,
/// SVG 绘图失败(仅 `--features plot`)。
#[error("plot error: {0}")]
Plot(String),
}
fn io(path: impl Into<PathBuf>, source: std::io::Error) -> ReportError {
ReportError::Io { path: path.into(), source }
}
/// 生成产物所需的上下文。
#[derive(Debug, Clone)]
pub struct ReportContext {
/// 输出目录。
pub out_dir: PathBuf,
/// 本次搜索使用的参数空间。
pub space: GainSpace,
/// 搜索配置。
pub config: SearchConfig,
/// 场景。
pub scenario: Scenario,
/// 限幅。
pub limits: LoopLimits,
/// 后端描述(`sim(...)` / `bench(...)`)。
pub backend: String,
/// 目标轮名(写回 TOML 用)。
pub motor_name: String,
/// 验收阈值:最大超调(%)。
pub max_overshoot_pct: f32,
/// 验收阈值:最大稳态误差(%)。
pub max_ss_error_pct: f32,
/// 最优增益的轨迹(有则写 `best-trace.csv`)。
pub best_trace: Option<Trace>,
}
/// 写出全部产物,返回写出的文件列表。
///
/// # Errors
///
/// 目录创建、文件写入或序列化失败时返回 [`ReportError`]。
pub fn write_reports(ctx: &ReportContext, ranked: &Ranked) -> Result<Vec<PathBuf>, ReportError> {
std::fs::create_dir_all(&ctx.out_dir).map_err(|e| io(&ctx.out_dir, e))?;
let mut written = Vec::new();
let trials_path = ctx.out_dir.join("trials.csv");
write_trials_csv(&trials_path, &ranked.trials)?;
written.push(trials_path);
let best_path = ctx.out_dir.join("best.toml");
write_best_toml(&best_path, ctx, &ranked.best)?;
written.push(best_path);
if let Some(trace) = &ctx.best_trace {
let trace_path = ctx.out_dir.join("best-trace.csv");
write_trace_csv(&trace_path, trace)?;
written.push(trace_path);
}
let report_path = ctx.out_dir.join("report.md");
write_markdown(&report_path, ctx, ranked)?;
written.push(report_path);
let summary_path = ctx.out_dir.join("summary.json");
write_summary(&summary_path, ctx, ranked)?;
written.push(summary_path);
#[cfg(feature = "plot")]
if let Some(trace) = &ctx.best_trace {
let svg_path = ctx.out_dir.join("best.svg");
crate::plot::write_step_svg(&svg_path, trace, &ctx.scenario)?;
written.push(svg_path);
}
Ok(written)
}
/// `trials.csv` 的一行。
#[derive(Debug, Serialize)]
struct TrialRow {
index: u64,
phase: &'static str,
kp: f32,
ki: f32,
kd: f32,
feedforward: f32,
cost: f64,
verdict: &'static str,
settled: bool,
rise_time_s: f32,
overshoot_pct: f32,
settling_time_s: f32,
ss_error_pct: f32,
steady_rpm: f32,
iae: f32,
peak_effort_raw: f32,
rms_effort_raw: f32,
saturated_pct: f32,
max_rpm: f32,
}
impl TrialRow {
fn from_trial(trial: &Trial) -> Self {
let metrics = trial.score.metrics.unwrap_or_default();
Self {
index: trial.index,
phase: trial.phase.label(),
kp: trial.gains.kp,
ki: trial.gains.ki,
kd: trial.gains.kd,
feedforward: trial.gains.feedforward,
cost: trial.score.cost,
verdict: trial.score.verdict.label(),
settled: metrics.settled,
rise_time_s: metrics.rise_time_s,
overshoot_pct: metrics.overshoot_pct,
settling_time_s: metrics.settling_time_s,
ss_error_pct: metrics.ss_error_pct,
steady_rpm: metrics.steady_rpm,
iae: metrics.iae,
peak_effort_raw: metrics.peak_effort_raw,
rms_effort_raw: metrics.rms_effort_raw,
saturated_pct: metrics.saturated_pct,
max_rpm: metrics.max_rpm,
}
}
}
fn write_trials_csv(path: &Path, trials: &[Trial]) -> Result<(), ReportError> {
let mut writer = csv::Writer::from_path(path)?;
for trial in trials {
writer.serialize(TrialRow::from_trial(trial))?;
}
writer.flush().map_err(|e| io(path, e))?;
Ok(())
}
/// 轨迹 CSV 的一行。
#[derive(Debug, Serialize)]
struct TraceRow {
t_s: f32,
setpoint_rpm: f32,
rpm: f32,
effort_raw: i16,
saturated: bool,
}
fn write_trace_csv(path: &Path, trace: &Trace) -> Result<(), ReportError> {
let mut writer = csv::Writer::from_path(path)?;
for index in 0..trace.len() {
writer.serialize(TraceRow {
t_s: trace.t_s[index],
setpoint_rpm: trace.setpoint_rpm[index],
rpm: trace.rpm[index],
effort_raw: trace.effort_raw[index],
saturated: trace.saturated[index],
})?;
}
writer.flush().map_err(|e| io(path, e))?;
Ok(())
}
/// 可粘贴进既有 `[[motor]]` 段落的增益补丁。
#[derive(Debug, Serialize)]
struct GainPatch {
kp: f32,
ki: f32,
kd: f32,
feedforward: f32,
}
fn write_best_toml(path: &Path, ctx: &ReportContext, best: &Trial) -> Result<(), ReportError> {
let gains_patch = GainPatch {
kp: best.gains.kp,
ki: best.gains.ki,
kd: best.gains.kd,
feedforward: best.gains.feedforward,
};
let mut text = String::new();
writeln!(text, "# 由 chassis-tune 生成(seed={}, backend={})", ctx.config.seed, ctx.backend)
.ok();
writeln!(
text,
"# 轮子:{}|场景:{}|限流 {} raw",
ctx.motor_name,
crate::search::scenario_summary(&ctx.scenario),
ctx.limits.current_limit_raw
)
.ok();
writeln!(text, "# 参数空间:{}", ctx.space.describe()).ok();
text.push_str("# 把下面四行粘进 chassis.toml 里对应的 [[motor]] 段落即可。\n");
text.push_str(&toml::to_string(&gains_patch)?);
std::fs::write(path, text).map_err(|e| io(path, e))?;
Ok(())
}
/// `summary.json` 的结构。
#[derive(Debug, Serialize)]
struct Summary<'a> {
backend: &'a str,
seed: u64,
samples: usize,
top_k: usize,
solver: &'static str,
evals: u64,
ok_trials: usize,
rejected_trials: usize,
scenario: String,
gain_space: String,
limits: LimitSummary,
thresholds: Thresholds,
best: BestSummary,
top: Vec<BestSummary>,
notes: Vec<String>,
}
#[derive(Debug, Serialize)]
struct LimitSummary {
current_limit_raw: i16,
}
#[derive(Debug, Serialize)]
struct Thresholds {
max_overshoot_pct: f32,
max_ss_error_pct: f32,
}
#[derive(Debug, Serialize)]
struct BestSummary {
index: u64,
phase: &'static str,
kp: f32,
ki: f32,
kd: f32,
feedforward: f32,
cost: f64,
verdict: &'static str,
metrics: Option<MetricsSummary>,
}
#[derive(Debug, Serialize)]
struct MetricsSummary {
rise_time_s: f32,
overshoot_pct: f32,
settling_time_s: f32,
ss_error_pct: f32,
steady_rpm: f32,
iae: f32,
rms_effort_raw: f32,
saturated_pct: f32,
max_rpm: f32,
}
impl MetricsSummary {
fn from_metrics(metrics: &StepMetrics) -> Self {
Self {
rise_time_s: metrics.rise_time_s,
overshoot_pct: metrics.overshoot_pct,
settling_time_s: metrics.settling_time_s,
ss_error_pct: metrics.ss_error_pct,
steady_rpm: metrics.steady_rpm,
iae: metrics.iae,
rms_effort_raw: metrics.rms_effort_raw,
saturated_pct: metrics.saturated_pct,
max_rpm: metrics.max_rpm,
}
}
}
fn best_summary(trial: &Trial) -> BestSummary {
BestSummary {
index: trial.index,
phase: trial.phase.label(),
kp: trial.gains.kp,
ki: trial.gains.ki,
kd: trial.gains.kd,
feedforward: trial.gains.feedforward,
cost: trial.score.cost,
verdict: trial.score.verdict.label(),
metrics: trial.score.metrics.as_ref().map(MetricsSummary::from_metrics),
}
}
fn write_summary(path: &Path, ctx: &ReportContext, ranked: &Ranked) -> Result<(), ReportError> {
let mut notes = Vec::new();
if !ranked.best.score.verdict.is_ok() {
notes.push(format!(
"没有任何候选达标:最优判定为 {}({})",
ranked.best.score.verdict.label(),
verdict_hint(ranked.best.score.verdict)
));
}
if let Some(metrics) = ranked.best.score.metrics {
if metrics.overshoot_pct > ctx.max_overshoot_pct {
notes.push(format!(
"最优解超调 {:.1}% 超过阈值 {:.1}%",
metrics.overshoot_pct, ctx.max_overshoot_pct
));
}
if metrics.ss_error_pct > ctx.max_ss_error_pct {
notes.push(format!(
"最优解稳态误差 {:.1}% 超过阈值 {:.1}%",
metrics.ss_error_pct, ctx.max_ss_error_pct
));
}
if metrics.saturated_pct > 5.0 {
notes.push(format!("最优解饱和占比 {:.1}%,限流可能偏紧", metrics.saturated_pct));
}
}
// 贴边界的警告排在"通用提醒"之前:它是本次搜索最具体的一条结论。
notes.extend(ctx.space.boundary_hits(&ranked.best.gains));
notes.push(
"仿真整定依赖被控对象模型;上真机前先用 `validate` 在台架上复验(--backend bench)。"
.into(),
);
let summary = Summary {
backend: &ctx.backend,
seed: ctx.config.seed,
samples: ctx.config.samples,
top_k: ctx.config.top_k,
solver: ctx.config.solver.name(),
evals: ranked.evals,
ok_trials: ranked.ok_count(),
rejected_trials: ranked.rejected,
scenario: crate::search::scenario_summary(&ctx.scenario),
gain_space: ctx.space.describe(),
limits: LimitSummary { current_limit_raw: ctx.limits.current_limit_raw },
thresholds: Thresholds {
max_overshoot_pct: ctx.max_overshoot_pct,
max_ss_error_pct: ctx.max_ss_error_pct,
},
best: best_summary(&ranked.best),
top: ranked.top(10).iter().map(best_summary).collect(),
notes,
};
let text = serde_json::to_string_pretty(&summary)?;
std::fs::write(path, text).map_err(|e| io(path, e))?;
Ok(())
}
fn write_markdown(path: &Path, ctx: &ReportContext, ranked: &Ranked) -> Result<(), ReportError> {
let mut text = String::new();
let best = &ranked.best;
writeln!(text, "# 速度环自动整定报告\n").ok();
write_markdown_conclusion(&mut text, ctx, best);
write_markdown_settings(&mut text, ctx, ranked);
write_markdown_top(&mut text, ranked);
write_markdown_artifacts(&mut text);
write_markdown_notes(&mut text, ctx, best);
std::fs::write(path, text).map_err(|e| io(path, e))?;
Ok(())
}
/// 结论段:最优增益、指标、验收、无解时的原因。
fn write_markdown_conclusion(text: &mut String, ctx: &ReportContext, best: &Trial) {
writeln!(text, "## 结论\n").ok();
writeln!(
text,
"**kp = {:.3} ki = {:.3} kd = {:.3} feedforward = {:.3}**(成本 {:.4},判定 {})\n",
best.gains.kp,
best.gains.ki,
best.gains.kd,
best.gains.feedforward,
best.cost(),
best.score.verdict.label()
)
.ok();
if let Some(metrics) = best.score.metrics {
writeln!(
text,
"- 上升时间 {:.0} ms|超调 {:.1}%|调节时间 {:.0} ms|稳态误差 {:.2}%",
metrics.rise_time_s * 1000.0,
metrics.overshoot_pct,
metrics.settling_time_s * 1000.0,
metrics.ss_error_pct
)
.ok();
writeln!(
text,
"- 峰值电流 {:.0} raw|RMS 电流 {:.0} raw|饱和 {:.1}%|峰值转速 {:.0} rpm",
metrics.peak_effort_raw, metrics.rms_effort_raw, metrics.saturated_pct, metrics.max_rpm
)
.ok();
writeln!(
text,
"- 验收:{}(阈值 超调 ≤ {:.1}%,稳态误差 ≤ {:.1}%)\n",
if ctx.best_passes(&metrics) { "✓ 通过" } else { "✗ 未通过" },
ctx.max_overshoot_pct,
ctx.max_ss_error_pct
)
.ok();
}
if !best.score.verdict.is_ok() {
writeln!(
text,
"> ⚠ 没有任何候选达标(最优:{})。{}\n",
best.score.verdict.label(),
verdict_hint(best.score.verdict)
)
.ok();
}
}
/// 设置段:复现本次搜索所需的全部输入。
fn write_markdown_settings(text: &mut String, ctx: &ReportContext, ranked: &Ranked) {
writeln!(text, "## 设置\n").ok();
writeln!(text, "| 项 | 值 |").ok();
writeln!(text, "| --- | --- |").ok();
writeln!(text, "| 后端 | `{}` |", ctx.backend).ok();
writeln!(text, "| 场景 | `{}` |", crate::search::scenario_summary(&ctx.scenario)).ok();
writeln!(text, "| 参数空间 | `{}` |", ctx.space.describe()).ok();
writeln!(text, "| 限流 | {} raw |", ctx.limits.current_limit_raw).ok();
writeln!(text, "| 种子 | {} |", ctx.config.seed).ok();
writeln!(
text,
"| 搜索 | samples={}, top_k={}, solver={}, max_iters={} |",
ctx.config.samples,
ctx.config.top_k,
ctx.config.solver.name(),
ctx.config.max_iters
)
.ok();
writeln!(
text,
"| 评估 | {} 次(达标 {} / 共 {}) |",
ranked.evals,
ranked.ok_count(),
ranked.trials.len()
)
.ok();
writeln!(text, "| 目标轮 | `{}` |\n", ctx.motor_name).ok();
}
/// Top-10 表格。
fn write_markdown_top(text: &mut String, ranked: &Ranked) {
writeln!(text, "## Top 10\n").ok();
writeln!(
text,
"| # | 阶段 | kp | ki | kd | ff | 成本 | 判定 | 超调 % | 稳态误差 % | 调节 ms |"
)
.ok();
writeln!(text, "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |").ok();
for (rank, trial) in ranked.top(10).iter().enumerate() {
let metrics = trial.score.metrics.unwrap_or_default();
writeln!(
text,
"| {} | {} | {:.3} | {:.3} | {:.3} | {:.3} | {:.4} | {} | {:.1} | {:.2} | {:.0} |",
rank + 1,
trial.phase.label(),
trial.gains.kp,
trial.gains.ki,
trial.gains.kd,
trial.gains.feedforward,
trial.cost(),
trial.score.verdict.label(),
metrics.overshoot_pct,
metrics.ss_error_pct,
metrics.settling_time_s * 1000.0
)
.ok();
}
writeln!(text).ok();
}
/// 产物清单。
fn write_markdown_artifacts(text: &mut String) {
writeln!(text, "## 产物\n").ok();
writeln!(text, "- `trials.csv`:全部候选(含未达标者与判定原因)").ok();
writeln!(text, "- `best.toml`:可粘贴的 `[[motor]]` 增益片段").ok();
writeln!(text, "- `best-trace.csv`:最优增益的完整轨迹").ok();
writeln!(text, "- `hil-validation.csv`:真机重复性复验(仅 `--backend bench`)").ok();
writeln!(text, "- `summary.json`:机器可读结果\n").ok();
}
/// 注意事项:把"仿真整定不能替代实车复验"写进报告,而不是只写在 README 里。
fn write_markdown_notes(text: &mut String, ctx: &ReportContext, best: &Trial) {
let hits = ctx.space.boundary_hits(&best.gains);
writeln!(text, "## 注意事项\n").ok();
writeln!(text, "1. 仿真整定只保证在被控对象模型内最优;模型参数(摩擦 / 延迟)是量级估计,")
.ok();
writeln!(
text,
" 上真机前请用同一 `--scenario` 跑 `chassis-tune sweep --backend bench` 复验。"
)
.ok();
writeln!(
text,
"2. 台架搜索会真的驱动轮子:必须架空或整车固定,且加 `--i-know-the-wheel-spins`。"
)
.ok();
writeln!(
text,
"3. `current_limit` 属于安全约束,不参与搜索;它会钳住 PID 输出,也决定饱和判定。"
)
.ok();
writeln!(text, "4. 复现本次结果:同一 `--seed` + 同一参数空间 → 逐位一致。").ok();
for hit in &hits {
writeln!(text, "- ⚠ 搜索落在参数空间边界:{hit}").ok();
}
writeln!(
text,
"5. 本模型不含测量噪声:仿真里更高的增益不会被噪声惩罚,因此最优解可能偏激进——\n 台架复验(`--backend bench`)是把它拉回现实的唯一手段。"
)
.ok();
}
impl ReportContext {
/// 一组指标是否满足验收阈值。
#[must_use]
pub fn best_passes(&self, metrics: &StepMetrics) -> bool {
metrics.settled
&& metrics.overshoot_pct <= self.max_overshoot_pct
&& metrics.ss_error_pct <= self.max_ss_error_pct
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::{Evaluator, SimEvaluator};
use crate::cost::{Objective, Weights};
use crate::gains::GainSpace;
use crate::search::{SearchConfig, sweep};
use dji_esc_sim::{LoopLimits, PlantEnsemble, PlantParams};
fn ranked(name: &str) -> (GainSpace, ReportContext, Ranked) {
let space = GainSpace::default_gm3508();
let scenario = Scenario::step(1500.0, 1.0);
let limits = LoopLimits::gm3508();
let objective = Objective::new(Weights::default(), scenario, limits);
let ensemble = PlantEnsemble::homogeneous(PlantParams::gm3508(), 1);
let evaluator = SimEvaluator::new(ensemble, scenario, limits, objective, 0.0);
let config = SearchConfig {
seed: 3,
samples: 48,
top_k: 2,
max_iters: 40,
max_evals: 900,
..SearchConfig::default()
};
let ranked = sweep(&space, &config, &evaluator, None).unwrap();
let trace =
dji_esc_sim::simulate(ranked.best.gains, &PlantParams::gm3508(), &scenario, limits)
.unwrap();
let ctx = ReportContext {
out_dir: std::env::temp_dir().join(format!("chassis-tune-report-{name}")),
space,
config,
scenario,
limits,
backend: evaluator.describe(),
motor_name: "front_left".into(),
max_overshoot_pct: 20.0,
max_ss_error_pct: 5.0,
best_trace: Some(trace),
};
(space, ctx, ranked)
}
#[test]
fn writes_every_artifact() {
let (_, ctx, ranked) = ranked("writes_every_artifact");
let _ = std::fs::remove_dir_all(&ctx.out_dir);
let written = write_reports(&ctx, &ranked).unwrap();
let names: Vec<String> = written
.iter()
.map(|p| p.file_name().unwrap_or_default().to_string_lossy().into_owned())
.collect();
for expected in ["trials.csv", "best.toml", "best-trace.csv", "report.md", "summary.json"] {
assert!(names.iter().any(|n| n == expected), "missing {expected}: {names:?}");
}
for path in &written {
assert!(path.exists(), "{} must exist", path.display());
}
}
#[test]
fn trials_csv_has_a_stable_header_and_one_row_per_trial() {
let (_, ctx, ranked) = ranked("trials_csv_has_a_stable_header_and_one_row_per_trial");
write_reports(&ctx, &ranked).unwrap();
let text = std::fs::read_to_string(ctx.out_dir.join("trials.csv")).unwrap();
let mut lines = text.lines();
let header = lines.next().unwrap();
for column in ["index", "phase", "kp", "ki", "kd", "feedforward", "cost", "verdict"] {
assert!(header.contains(column), "header missing {column}: {header}");
}
assert_eq!(lines.count(), ranked.trials.len());
}
#[test]
fn best_toml_parses_back_through_the_config_schema() {
let (_, ctx, ranked) = ranked("best_toml_parses_back_through_the_config_schema");
write_reports(&ctx, &ranked).unwrap();
let text = std::fs::read_to_string(ctx.out_dir.join("best.toml")).unwrap();
// 片段本体是合法 TOML:与其它段落拼起来即可被 chassis-config 读回。
let patch = text
.lines()
.filter(|line| !line.trim_start().starts_with('#'))
.collect::<Vec<_>>()
.join("\n");
let full = format!(
"[can]\ninterface = \"can0\"\n[mecanum]\nhalf_length = 0.2\nhalf_width = 0.25\nwheel_radius = 0.0762\n\n[[motor]]\nname = \"front_left\"\nesc_id = 1\ndirection = 1\ngear_ratio = 19.203\n{patch}"
);
let mut motors = Vec::new();
for index in 1..4 {
motors.push(format!(
"[[motor]]\nname = \"w{index}\"\nesc_id = {}\ndirection = 1\ngear_ratio = 19.203\n",
index + 1
));
}
let full = format!("{full}\n{}", motors.join("\n"));
let config = chassis_config::Config::from_toml(&full).expect("best.toml must be readable");
let gains = config.motors[0].velocity_gains();
assert_eq!(gains, ranked.best.gains);
}
#[test]
fn summary_json_is_machine_readable() {
let (_, ctx, ranked) = ranked("summary_json_is_machine_readable");
write_reports(&ctx, &ranked).unwrap();
let text = std::fs::read_to_string(ctx.out_dir.join("summary.json")).unwrap();
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(value["seed"], serde_json::json!(3));
assert_eq!(value["samples"], serde_json::json!(48));
assert!(value["best"]["kp"].as_f64().unwrap() > 0.0);
assert!(value["top"].as_array().unwrap().len() <= 10);
assert!(!value["notes"].as_array().unwrap().is_empty());
}
#[test]
fn markdown_mentions_the_gains_and_thresholds() {
let (_, ctx, ranked) = ranked("markdown_mentions_the_gains_and_thresholds");
write_reports(&ctx, &ranked).unwrap();
let text = std::fs::read_to_string(ctx.out_dir.join("report.md")).unwrap();
assert!(text.contains("kp ="), "{text}");
assert!(text.contains("Top 10"), "{text}");
assert!(text.contains("front_left"), "{text}");
assert!(text.contains("注意事项"), "{text}");
}
#[test]
fn best_trace_csv_matches_the_trace_length() {
let (_, ctx, ranked) = ranked("best_trace_csv_matches_the_trace_length");
write_reports(&ctx, &ranked).unwrap();
let text = std::fs::read_to_string(ctx.out_dir.join("best-trace.csv")).unwrap();
let rows = text.lines().count();
let expected = ctx.best_trace.as_ref().unwrap().len() + 1; // + 表头
assert_eq!(rows, expected);
}
#[test]
fn best_passes_respects_the_thresholds() {
let (_, ctx, _) = ranked("t");
let passing = StepMetrics {
settled: true,
overshoot_pct: 10.0,
ss_error_pct: 1.0,
..StepMetrics::default()
};
assert!(ctx.best_passes(&passing));
let overshooting = StepMetrics { overshoot_pct: 50.0, ..passing };
assert!(!ctx.best_passes(&overshooting));
let unsettled = StepMetrics { settled: false, ..passing };
assert!(!ctx.best_passes(&unsettled));
}
}
-628
View File
@@ -1,628 +0,0 @@
//! 两阶段参数空间搜索:种子化分层粗扫 → `argmin` 多起点局部精修。
//!
//! # 为什么是两阶段
//!
//! 成本函数有一大片"罚值平原"(不收敛 / 饱和的候选成本都等于 `PENALTY`),
//! 纯局部搜索会陷在平原上;而纯网格搜索在四个数量级跨度上需要天文数字的
//! 评估次数。两阶段各取所长:
//!
//! 1. **粗扫**:Latin Hypercube 分层采样 + `rayon` 并行评估,用有限次数把
//! 空间铺满,拿到若干个互不相邻的可达解(top-k);
//! 2. **精修**:以每个可达解为起点做多起点 `argmin` 局部搜索(Nelder-Mead
//! 或 PSO),在**坐标空间**(对数刻度)里收敛到局部最优。
//!
//! # 可复现性
//!
//! 每个候选的随机性只来自 `(种子, 候选下标)` 派生的独立 RNG,结果与线程
//! 调度无关;粗扫结果按下标排序后再取 top-k,因此同 seed 两次运行逐位一致。
use std::sync::atomic::{AtomicU64, Ordering};
use argmin::core::{CostFunction, Error as ArgminError, Executor};
use argmin::solver::neldermead::NelderMead;
use argmin::solver::particleswarm::ParticleSwarm;
use dji_esc_sim::Scenario;
use indicatif::ProgressBar;
use motor_core::VelocityGains;
use rand::SeedableRng as _;
use rand::rngs::StdRng;
use rayon::prelude::*;
use thiserror::Error;
use crate::backend::Evaluator;
use crate::cost::{Score, Verdict};
use crate::gains::{AXIS_COUNT, GainSpace};
/// 搜索阶段的错误。
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SearchError {
/// 参数空间非法。
#[error("invalid gain space: {0}")]
InvalidSpace(String),
/// 粗扫样本数为 0。
#[error("samples must be >= 1")]
NoSamples,
}
/// 候选来自哪个阶段。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
/// 分层粗扫。
Coarse,
/// 局部精修。
Refine,
/// 用户指定 / 基线点。
Seed,
}
impl Phase {
/// CSV / 报告标签。
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Coarse => "coarse",
Self::Refine => "refine",
Self::Seed => "seed",
}
}
}
/// 一次被评估的候选。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Trial {
/// 评估序号(按评估顺序递增,报告可复现)。
pub index: u64,
/// 所属阶段。
pub phase: Phase,
/// 增益。
pub gains: VelocityGains,
/// 评估结果。
pub score: Score,
}
impl Trial {
/// 成本。
#[must_use]
pub fn cost(&self) -> f64 {
self.score.cost
}
}
/// 局部精修求解器。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SolverKind {
/// Nelder-Mead(无导数、对罚值平原鲁棒),默认。
#[default]
NelderMead,
/// 粒子群(更全局,评估次数更高)。
Pso,
}
impl SolverKind {
/// 文本名。
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::NelderMead => "nelder-mead",
Self::Pso => "pso",
}
}
/// 从文本解析。
///
/// # Errors
///
/// 未知名字时返回错误。
pub fn parse(text: &str) -> Result<Self, SearchError> {
match text {
"nelder-mead" | "neldermead" | "nm" => Ok(Self::NelderMead),
"pso" | "particle-swarm" => Ok(Self::Pso),
other => Err(SearchError::InvalidSpace(format!(
"unknown solver '{other}' (expected nelder-mead|pso)"
))),
}
}
}
/// 搜索配置。
#[derive(Debug, Clone, PartialEq)]
pub struct SearchConfig {
/// 种子(决定分层采样与多起点顺序)。
pub seed: u64,
/// 粗扫样本数。
pub samples: usize,
/// 进入精修的种子数量。
pub top_k: usize,
/// 精修求解器。
pub solver: SolverKind,
/// 每个起点的最大迭代数。
pub max_iters: usize,
/// PSO 粒子数(`SolverKind::Pso` 时生效)。
pub particles: usize,
/// 评估预算上限(含粗扫)。
pub max_evals: u64,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
seed: 20_260_101,
samples: 512,
top_k: 8,
solver: SolverKind::NelderMead,
max_iters: 200,
particles: 24,
max_evals: 4_000,
}
}
}
impl SearchConfig {
/// 校验。
///
/// 预算小于样本数**不是**错误:[`sweep`] 会把预算下限抬到 `samples`
/// (粗扫必须能把铺满空间的样本跑完),因此只有"没有样本"才是硬错误。
///
/// # Errors
///
/// 样本数为 0 时返回 [`SearchError::NoSamples`]。
pub fn validate(&self) -> Result<(), SearchError> {
if self.samples == 0 {
return Err(SearchError::NoSamples);
}
Ok(())
}
}
/// 搜索结果(按成本升序)。
#[derive(Debug, Clone)]
pub struct Ranked {
/// 全部候选(升序)。
pub trials: Vec<Trial>,
/// 最优候选。
pub best: Trial,
/// 实际评估次数。
pub evals: u64,
/// 被硬约束拦下的候选数。
pub rejected: usize,
}
impl Ranked {
/// 前 `n` 名。
#[must_use]
pub fn top(&self, n: usize) -> &[Trial] {
let n = n.min(self.trials.len());
&self.trials[..n]
}
/// 达到 `Verdict::Ok` 的候选数。
#[must_use]
pub fn ok_count(&self) -> usize {
self.trials.iter().filter(|t| t.score.verdict.is_ok()).count()
}
}
/// 共享的评估预算与计数器。
struct Budget {
used: AtomicU64,
limit: u64,
}
impl Budget {
fn new(limit: u64) -> Self {
Self { used: AtomicU64::new(0), limit }
}
/// 取一个评估配额;返回 `None` 表示预算耗尽。
///
/// 用 CAS 而不是 `fetch_add`:预算耗尽后优化器仍会持续询问,若仍自增,
/// `spent()` 就会把"被拒绝的询问"也算成评估次数,报告里的数字便失去意义。
fn take(&self) -> Option<u64> {
let mut current = self.used.load(Ordering::Relaxed);
loop {
if current >= self.limit {
return None;
}
match self.used.compare_exchange_weak(
current,
current + 1,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return Some(current),
Err(observed) => current = observed,
}
}
}
fn spent(&self) -> u64 {
self.used.load(Ordering::Relaxed)
}
}
/// 按预算评估一组增益(预算耗尽时直接给罚值,不再花时间算)。
fn evaluate_with_budget(
budget: &Budget,
evaluator: &dyn Evaluator,
gains: &VelocityGains,
) -> Score {
if budget.take().is_some() {
evaluator.evaluate(gains)
} else {
Score::penalized(Verdict::Rejected)
}
}
/// 记录本次精修见过的最优解。
///
/// 不依赖 `argmin` 的结果访问器(跨版本不稳定),而是让成本函数自己记账:
/// 谁算出过最低成本,谁就是这次精修的结果。
#[derive(Default)]
struct BestTracker {
best: std::sync::Mutex<Option<(f64, [f64; AXIS_COUNT])>>,
}
impl BestTracker {
fn new() -> Self {
Self::default()
}
fn record(&self, cost: f64, coords: [f64; AXIS_COUNT]) {
let mut best = self.best.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if best.is_none_or(|(current, _)| cost < current) {
*best = Some((cost, coords));
}
}
fn take(&self) -> Option<(f64, [f64; AXIS_COUNT])> {
*self.best.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
/// 把 [`Evaluator`] 适配成 `argmin` 的成本函数(坐标空间参数)。
struct CoordProblem<'a> {
evaluator: &'a dyn Evaluator,
space: &'a GainSpace,
budget: &'a Budget,
tracker: &'a BestTracker,
}
impl CostFunction for CoordProblem<'_> {
type Param = Vec<f64>;
type Output = f64;
fn cost(&self, param: &Self::Param) -> Result<f64, ArgminError> {
let coords: [f64; AXIS_COUNT] = match param.as_slice() {
[a, b, c, d] => [*a, *b, *c, *d],
_ => return Ok(crate::cost::PENALTY),
};
let gains = self.space.from_coords(coords);
let cost = match self.budget.take() {
Some(_) => self.evaluator.evaluate(&gains).cost,
None => crate::cost::PENALTY,
};
if cost.is_finite() {
self.tracker.record(cost, coords);
}
Ok(cost)
}
}
/// 以 `start` 为起点做一次局部精修,返回见过的最优增益。
fn refine(
space: &GainSpace,
evaluator: &dyn Evaluator,
budget: &Budget,
config: &SearchConfig,
start: VelocityGains,
seed: u64,
) -> VelocityGains {
let (lower, upper) = space.coord_bounds();
let tracker = BestTracker::new();
let problem = CoordProblem { evaluator, space, budget, tracker: &tracker };
let start_coords = space.to_coords(&start);
// 两种求解器的 `OptimizationResult` 类型不同,这里只保留"成功 / 失败"。
let outcome: Result<(), ArgminError> = match config.solver {
SolverKind::NelderMead => {
// n 维需要 n+1 个初始顶点:起点 + 每轴一小步(轴跨度的 5%)。
let mut simplex: Vec<Vec<f64>> = Vec::with_capacity(AXIS_COUNT + 1);
simplex.push(start_coords.to_vec());
for axis in 0..AXIS_COUNT {
let mut vertex = start_coords;
let span = (upper[axis] - lower[axis]).abs();
vertex[axis] = (vertex[axis] + 0.05 * span.max(1.0e-3)).min(upper[axis]);
simplex.push(vertex.to_vec());
}
let solver: NelderMead<Vec<f64>, f64> = NelderMead::new(simplex);
Executor::new(problem, solver)
.configure(|state| state.max_iters(config.max_iters as u64))
.run()
.map(|_| ())
}
SolverKind::Pso => {
let solver: ParticleSwarm<Vec<f64>, f64, StdRng> =
ParticleSwarm::new((lower.to_vec(), upper.to_vec()), config.particles.max(2))
.with_rng_generator(StdRng::seed_from_u64(seed));
Executor::new(problem, solver)
.configure(|state| state.max_iters(config.max_iters as u64))
.run()
.map(|_| ())
}
};
// 求解器失败(预算耗尽 / 数值问题)不算错:退化到起点,搜索继续。
if let Err(error) = outcome {
tracing::debug!(%error, "local refinement stopped early");
}
tracker.take().map_or(start, |(_, coords)| space.from_coords(coords))
}
/// 执行两阶段搜索。
///
/// # Errors
///
/// 参数空间或配置非法时返回 [`SearchError`]。
pub fn sweep(
space: &GainSpace,
config: &SearchConfig,
evaluator: &dyn Evaluator,
progress: Option<&ProgressBar>,
) -> Result<Ranked, SearchError> {
space.validate().map_err(|e| SearchError::InvalidSpace(e.to_string()))?;
config.validate()?;
let budget = Budget::new(config.max_evals.max(config.samples as u64));
// ---------------- 阶段 1:分层粗扫(并行) ----------------
let mut rng = StdRng::seed_from_u64(config.seed);
let candidates = space.stratified(config.samples, &mut rng);
let mut trials: Vec<Trial> = candidates
.into_par_iter()
.enumerate()
.map(|(index, gains)| Trial {
index: index as u64,
phase: Phase::Coarse,
gains,
score: evaluate_with_budget(&budget, evaluator, &gains),
})
.collect();
if let Some(bar) = progress {
bar.inc(trials.len() as u64);
}
// 排序后再取 top-k:与线程调度无关,同 seed 必定选出同一批种子。
trials.sort_by(|a, b| a.cost().total_cmp(&b.cost()).then(a.index.cmp(&b.index)));
let seeds: Vec<Trial> = trials.iter().take(config.top_k.max(1)).copied().collect();
// ---------------- 阶段 2:多起点精修(并行) ----------------
let coarse_count = trials.len() as u64;
let refined: Vec<Trial> = seeds
.par_iter()
.enumerate()
.map(|(slot, seed_trial)| {
let seed = config.seed.wrapping_add((slot as u64 + 1).wrapping_mul(0x9E37_79B9));
let gains = refine(space, evaluator, &budget, config, seed_trial.gains, seed);
let score = evaluate_with_budget(&budget, evaluator, &gains);
Trial { index: coarse_count + slot as u64, phase: Phase::Refine, gains, score }
})
.collect();
if let Some(bar) = progress {
bar.inc(refined.len() as u64);
}
trials.extend(refined);
trials.sort_by(|a, b| a.cost().total_cmp(&b.cost()).then(a.index.cmp(&b.index)));
let evals = budget.spent();
let rejected = trials.iter().filter(|t| !t.score.verdict.is_ok()).count();
let best = trials.first().copied().ok_or(SearchError::NoSamples)?;
Ok(Ranked { trials, best, evals, rejected })
}
/// 只评估一组增益(`validate` 子命令)。
#[must_use]
pub fn evaluate_once(evaluator: &dyn Evaluator, gains: &VelocityGains) -> Trial {
Trial { index: 0, phase: Phase::Seed, gains: *gains, score: evaluator.evaluate(gains) }
}
/// 一次评估都未达标时的提示(报告用)。
#[must_use]
pub fn verdict_hint(verdict: Verdict) -> &'static str {
match verdict {
Verdict::Ok => "达标",
Verdict::Rejected => "参数非法或场景退化",
Verdict::Diverged => "数值发散:增益过大",
Verdict::Overspeed => "过速:增益过大或前馈过高",
Verdict::Saturated => "长时间饱和:限流太紧或积分过大",
Verdict::NoSettle => "未收敛:增益过小或积分不足(空载台架考虑 ff=0)",
}
}
/// 报告用的场景摘要。
#[must_use]
pub fn scenario_summary(scenario: &Scenario) -> String {
format!(
"{:?} target={:.0} rpm ramp={:.0}ms hold={:.0}ms dt={:.1}ms",
scenario.profile,
scenario.target_rpm,
scenario.ramp_s * 1000.0,
scenario.hold_s * 1000.0,
scenario.dt_s * 1000.0
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::SimEvaluator;
use crate::cost::{Objective, Weights};
use dji_esc_sim::{LoopLimits, PlantEnsemble, PlantParams, PlantTolerances};
fn space() -> GainSpace {
GainSpace::default_gm3508()
}
fn evaluator(plant: PlantParams, ensemble: usize, robust_weight: f64) -> SimEvaluator {
let scenario = Scenario::step(1500.0, 2.0);
let limits = LoopLimits::gm3508();
let objective = Objective::new(Weights::default(), scenario, limits);
let ensemble = PlantEnsemble::randomized(plant, PlantTolerances::default(), ensemble, 7);
SimEvaluator::new(ensemble, scenario, limits, objective, robust_weight)
}
fn small_config(seed: u64) -> SearchConfig {
SearchConfig {
seed,
samples: 64,
top_k: 3,
max_iters: 60,
max_evals: 2_000,
..SearchConfig::default()
}
}
#[test]
fn sweep_finds_a_usable_configuration() {
let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0);
let ranked = sweep(&space(), &small_config(1), &evaluator, None).unwrap();
let best = ranked.best;
assert_eq!(best.score.verdict, Verdict::Ok, "{best:?}");
let metrics = best.score.metrics.unwrap();
assert!(metrics.ss_error_pct < 5.0, "ss = {}", metrics.ss_error_pct);
assert!(metrics.overshoot_pct < 30.0, "overshoot = {}", metrics.overshoot_pct);
}
#[test]
fn sweep_beats_the_documented_hand_tuned_baseline() {
let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0);
// README 记录的空载台架起步整定:kp=2, ki=10, kd=0, ff=0。
let baseline = evaluator.evaluate(&VelocityGains::new(2.0, 10.0, 0.0, 0.0));
let ranked = sweep(&space(), &small_config(17), &evaluator, None).unwrap();
assert!(
ranked.best.cost() < baseline.cost,
"search {} must beat baseline {}",
ranked.best.cost(),
baseline.cost
);
}
#[test]
fn results_are_reproducible_for_a_fixed_seed() {
let evaluator = evaluator(PlantParams::gm3508_bench(), 4, 0.5);
let a = sweep(&space(), &small_config(99), &evaluator, None).unwrap();
let b = sweep(&space(), &small_config(99), &evaluator, None).unwrap();
assert_eq!(a.best.gains, b.best.gains, "同 seed 必须给出同一组增益");
assert_eq!(a.best.cost().to_bits(), b.best.cost().to_bits());
assert_eq!(a.trials.len(), b.trials.len());
for (x, y) in a.trials.iter().zip(&b.trials) {
assert_eq!(x.gains, y.gains);
assert_eq!(x.cost().to_bits(), y.cost().to_bits());
}
}
#[test]
fn different_seeds_explore_differently() {
let evaluator = evaluator(PlantParams::gm3508_bench(), 2, 0.5);
let a = sweep(&space(), &small_config(1), &evaluator, None).unwrap();
let b = sweep(&space(), &small_config(2), &evaluator, None).unwrap();
let same_phase1: Vec<_> =
a.trials.iter().filter(|t| t.phase == Phase::Coarse).map(|t| t.gains).collect();
let other: Vec<_> =
b.trials.iter().filter(|t| t.phase == Phase::Coarse).map(|t| t.gains).collect();
assert_ne!(same_phase1, other);
}
#[test]
fn refined_trials_are_not_worse_than_their_seed() {
let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0);
let ranked = sweep(&space(), &small_config(5), &evaluator, None).unwrap();
let coarse_best = ranked
.trials
.iter()
.filter(|t| t.phase == Phase::Coarse)
.map(Trial::cost)
.fold(f64::INFINITY, f64::min);
assert!(
ranked.best.cost() <= coarse_best + 1e-9,
"best {} vs coarse best {coarse_best}",
ranked.best.cost()
);
assert!(ranked.trials.iter().any(|t| t.phase == Phase::Refine));
}
#[test]
fn pso_solver_also_improves_or_holds() {
let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0);
let mut config = small_config(3);
config.solver = SolverKind::Pso;
config.particles = 8;
config.max_iters = 12;
let ranked = sweep(&space(), &config, &evaluator, None).unwrap();
assert_eq!(ranked.best.score.verdict, Verdict::Ok, "{:?}", ranked.best);
}
#[test]
fn budget_bounds_the_number_of_evaluations() {
let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0);
let mut config = small_config(4);
config.max_evals = 80;
let ranked = sweep(&space(), &config, &evaluator, None).unwrap();
assert!(ranked.evals <= 84, "evals = {}", ranked.evals);
}
#[test]
fn invalid_space_is_rejected() {
let mut bad = space();
bad.kp.min = -1.0;
let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0);
assert!(matches!(
sweep(&bad, &small_config(1), &evaluator, None),
Err(SearchError::InvalidSpace(_))
));
}
#[test]
fn zero_samples_is_rejected() {
let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0);
let mut config = small_config(1);
config.samples = 0;
assert_eq!(sweep(&space(), &config, &evaluator, None).err(), Some(SearchError::NoSamples));
}
#[test]
fn solver_kind_parsing_round_trips() {
assert_eq!(SolverKind::parse("nelder-mead").unwrap(), SolverKind::NelderMead);
assert_eq!(SolverKind::parse("pso").unwrap(), SolverKind::Pso);
assert!(SolverKind::parse("simulated-annealing").is_err());
assert_eq!(SolverKind::default().name(), "nelder-mead");
}
#[test]
fn evaluate_once_reports_the_same_verdict() {
let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0);
let trial = evaluate_once(&evaluator, &VelocityGains::new(4.0, 12.0, 0.0, 0.0));
assert_eq!(trial.phase, Phase::Seed);
assert_eq!(trial.score.verdict, Verdict::Ok);
assert!(trial.score.metrics.is_some());
}
#[test]
fn verdict_hints_cover_every_verdict() {
for verdict in [
Verdict::Ok,
Verdict::Rejected,
Verdict::Diverged,
Verdict::Overspeed,
Verdict::Saturated,
Verdict::NoSettle,
] {
assert!(!verdict_hint(verdict).is_empty());
}
}
#[test]
fn scenario_summary_mentions_the_key_numbers() {
let text = scenario_summary(&Scenario::step(1200.0, 1.0));
assert!(text.contains("1200 rpm"), "{text}");
assert!(text.contains("1000ms"), "{text}");
}
}
+10 -10
View File
@@ -1,28 +1,28 @@
[package] [package]
name = "chassisd" name = "chassisd"
description = "Mecanum chassis daemon"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
description = "Mecanum chassis daemon: configuration, 1 kHz control loop, safety supervision, and mock mode"
[lints] [lints]
workspace = true workspace = true
[dependencies] [dependencies]
chassis-config = { workspace = true } can = { workspace = true }
motor-core = { workspace = true } motor = { workspace = true }
dji-esc = { workspace = true } esc-bus = { workspace = true }
dji-esc-sim = { workspace = true } motor-control = { workspace = true }
dji-esc-socketcan = { workspace = true }
mecanum = { workspace = true } mecanum = { workspace = true }
chassis-config = { workspace = true }
sim-harness = { workspace = true }
socketcan = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
thiserror = { workspace = true } clap = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
clap = { workspace = true }
[dev-dependencies] [dev-dependencies]
proptest = { workspace = true }
approx = { workspace = true }
tokio = { workspace = true, features = ["test-util"] } tokio = { workspace = true, features = ["test-util"] }
approx = { workspace = true }
-197
View File
@@ -1,197 +0,0 @@
//! 底盘控制循环:麦轮逆解 → 四电机速度指令,含安全逻辑。
use std::time::Duration;
use mecanum::{Mecanum, Twist};
use motor_core::{Disable, MotorFeedback, OutputShaft, VelocityDrive};
use tokio::sync::watch;
use tokio::task::JoinHandle;
use tokio::time::{Instant, MissedTickBehavior, interval};
use tracing::{info, warn};
/// 底盘控制器。
///
/// 职责(1kHz 节拍):
/// 1. 读取最新 twist 指令,**指令超时则归零**;
/// 2. 速度限幅(保持方向按比例收缩);
/// 3. **上电门控**:四台电机都收到过反馈才下发速度,否则全部停用;
/// 4. 麦轮逆解 → 各轮目标转速(rpm,轮端)→ 经 [`OutputShaft`] 换算到转子。
///
/// 电机反馈离线安全在驱动层完成(强制 0 电流)。
pub struct ChassisController<W> {
kinematics: Mecanum,
/// 顺序固定:FL、FR、RL、RR。轮端单位(经 `OutputShaft` 包装)。
wheels: [W; 4],
/// (max_vx, max_vy, max_omega)。
limits: (f32, f32, f32),
command_timeout: Duration,
}
impl<W> ChassisController<W>
where
W: VelocityDrive + MotorFeedback + Disable + 'static,
{
/// 组装控制器。`wheels` 顺序必须为 FL、FR、RL、RR。
#[must_use]
pub fn new(
kinematics: Mecanum,
wheels: [W; 4],
limits: (f32, f32, f32),
command_timeout: Duration,
) -> Self {
Self { kinematics, wheels, limits, command_timeout }
}
/// 启动控制循环任务。
pub fn spawn(self, commands: watch::Receiver<Twist>, period: Duration) -> JoinHandle<()> {
tokio::spawn(self.run(commands, period))
}
async fn run(self, mut commands: watch::Receiver<Twist>, period: Duration) {
let mut ticker = interval(period);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut latest = Twist::ZERO;
let mut last_command_at: Option<Instant> = None;
let mut ticks: u64 = 0;
loop {
ticker.tick().await;
let now = Instant::now();
if commands.has_changed().unwrap_or(false) {
latest = *commands.borrow_and_update();
last_command_at = Some(now);
}
let timed_out = match last_command_at {
Some(t) => now.duration_since(t) > self.command_timeout,
// 尚未收到任何指令:保持静止。
None => true,
};
let twist = if timed_out {
Twist::ZERO
} else {
latest.clamped(self.limits.0, self.limits.1, self.limits.2)
};
let all_seen_feedback = self.wheels.iter().all(|m| m.state().has_feedback());
if all_seen_feedback {
let wheel_rpms = self.kinematics.wheel_rpms(&twist);
for (motor, wheel_rpm) in self.wheels.iter().zip(wheel_rpms) {
motor.set_velocity(wheel_rpm);
}
} else {
for motor in &self.wheels {
motor.disable();
}
if ticks.is_multiple_of(1000) {
warn!("waiting for first feedback from all motors");
}
}
ticks += 1;
if ticks.is_multiple_of(1000) {
self.log_status();
}
}
}
fn log_status(&self) {
let (rotor_rpm, effort_raw): (Vec<f32>, Vec<i32>) = self
.wheels
.iter()
.map(|m| {
let st = m.state();
(st.rotor_speed_rpm, i32::from(st.effort_raw))
})
.unzip();
info!(?rotor_rpm, ?effort_raw, "chassis status");
}
}
/// 由 `EscMotor` 列表组装轮端包装(`gear_ratio` = 转子 : 输出轴)。
#[must_use]
pub fn wrap_wheels<T: Clone, const N: usize>(
motors: [T; N],
gear_ratios: [f32; N],
) -> [OutputShaft<T>; N] {
let mut out = Vec::with_capacity(N);
let mut gears = gear_ratios.into_iter();
for m in motors {
let g = gears.next().unwrap_or(1.0);
out.push(OutputShaft::new(m, g));
}
out.try_into().unwrap_or_else(|_| unreachable!())
}
#[cfg(test)]
mod tests {
use super::*;
use motor_core::{Command, MockMotor, MotorState};
const GEAR: f32 = 19.203;
fn mock_wheel(id: u8) -> MockMotor {
let m = MockMotor::new(id);
m.inject_state(MotorState { feedback_count: 1, ..MotorState::default() });
m
}
/// 证明 `ChassisController` 只依赖能力 trait:换 `MockMotor` 原样工作。
#[tokio::test(start_paused = true)]
async fn controller_drives_wheels_through_capability_traits() {
let kinematics = Mecanum::new(0.2, 0.25, 0.0762);
let mocks = [mock_wheel(1), mock_wheel(2), mock_wheel(3), mock_wheel(4)];
let ctrl = ChassisController::new(
kinematics,
wrap_wheels(mocks.clone(), [GEAR; 4]),
(3.0, 3.0, 6.0),
Duration::from_millis(200),
);
let (tx, rx) = watch::channel(Twist::ZERO);
let handle = ctrl.spawn(rx, Duration::from_millis(1));
let twist = Twist { vx: 1.0, ..Twist::ZERO };
tx.send_replace(twist);
tokio::time::sleep(Duration::from_millis(5)).await;
let expected = kinematics.wheel_rpms(&twist);
for (i, mock) in mocks.iter().enumerate() {
match mock.last_command() {
Command::Velocity(rotor_rpm) => {
let want = expected[i] * GEAR;
assert!(
(rotor_rpm - want).abs() < 1e-2,
"wheel {i}: rotor_rpm {rotor_rpm} != {want}"
);
}
ref cmd => panic!("wheel {i} expected Velocity, got {cmd:?}"),
}
}
handle.abort();
}
/// 反馈未到齐时必须全部停用,不得下发速度。
#[tokio::test(start_paused = true)]
async fn disables_wheels_until_all_feedback_seen() {
let kinematics = Mecanum::new(0.2, 0.25, 0.0762);
let ready = mock_wheel(1);
let missing = MockMotor::new(2); // 无反馈
let mocks_ready = [ready.clone(), mock_wheel(3), mock_wheel(4)];
let _ = mocks_ready;
let wheels =
wrap_wheels([ready.clone(), missing.clone(), mock_wheel(3), mock_wheel(4)], [GEAR; 4]);
let ctrl =
ChassisController::new(kinematics, wheels, (3.0, 3.0, 6.0), Duration::from_millis(200));
let (tx, rx) = watch::channel(Twist::ZERO);
let handle = ctrl.spawn(rx, Duration::from_millis(1));
tx.send_replace(Twist { vx: 1.0, ..Twist::ZERO });
tokio::time::sleep(Duration::from_millis(5)).await;
assert_eq!(ready.last_command(), Command::Disabled);
assert_eq!(missing.last_command(), Command::Disabled);
handle.abort();
}
}
-20
View File
@@ -1,20 +0,0 @@
//! `chassisd` 库部分:底盘控制循环与指令源抽象。
//!
//! 配置 schema 已独立为 [`chassis_config`](守护进程与整定工具共用一份
//! 定义),这里以 `config` 别名重新导出,保持 `chassisd::config::Config`
//! 这一历史路径可用。
//!
//! 二进制入口见 `src/main.rs`。
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub use chassis_config as config;
pub mod controller;
pub mod source;
pub use chassis_config::{
CanConfig, ChassisConfig, Config, ConfigError, FunctionBusConfig, MecanumConfig, MotorConfig,
ProtocolFamily,
};
pub use controller::{ChassisController, wrap_wheels};
pub use source::{ChannelSource, TwistSource, ZeroSource, pump_source};
File diff suppressed because it is too large Load Diff
-81
View File
@@ -1,81 +0,0 @@
//! 上层速度指令源(twist source)。
//!
//! 守护进程通过 [`watch`] 通道把指令交给控制循环;指令源在独立任务中
//! 持续产出 twist。遥控器(DR16)、自主导航等后续接入时各自实现
//! [`TwistSource`] 即可。
use std::future::Future;
use mecanum::Twist;
use tokio::sync::{mpsc, watch};
/// 速度指令源。
pub trait TwistSource: Send {
/// 取下一条指令;`None` 表示源关闭。
fn next_twist(&mut self) -> impl Future<Output = Option<Twist>> + Send;
}
/// 恒零占位源:永不产出(watch 保持初值 `Twist::ZERO`,指令超时逻辑兜底)。
pub struct ZeroSource;
impl TwistSource for ZeroSource {
async fn next_twist(&mut self) -> Option<Twist> {
std::future::pending().await
}
}
/// 通道指令源(测试 / 简单进程内注入用)。
pub struct ChannelSource {
rx: mpsc::Receiver<Twist>,
}
impl ChannelSource {
/// 由 mpsc 接收端构造。
#[must_use]
pub const fn new(rx: mpsc::Receiver<Twist>) -> Self {
Self { rx }
}
/// 创建一对(发送端, 源)。
#[must_use]
pub fn pair(bound: usize) -> (mpsc::Sender<Twist>, Self) {
let (tx, rx) = mpsc::channel(bound);
(tx, Self { rx })
}
}
impl TwistSource for ChannelSource {
async fn next_twist(&mut self) -> Option<Twist> {
self.rx.recv().await
}
}
/// 把任意指令源持续泵入 watch 通道(源关闭或接收端关闭时退出)。
pub async fn pump_source(mut source: impl TwistSource, tx: watch::Sender<Twist>) {
while let Some(twist) = source.next_twist().await {
if tx.send(twist).is_err() {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(start_paused = true)]
async fn channel_source_pumps_into_watch() {
let (tx, source) = ChannelSource::pair(8);
let (watch_tx, mut watch_rx) = watch::channel(Twist::ZERO);
let pump = tokio::spawn(pump_source(source, watch_tx));
tx.send(Twist { vx: 1.0, vy: 0.0, omega: 0.0 }).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
assert_eq!(*watch_rx.borrow_and_update(), Twist { vx: 1.0, vy: 0.0, omega: 0.0 });
// 源关闭 → 泵任务退出。
drop(tx);
pump.await.unwrap();
assert!((watch_rx.borrow().vx - 1.0).abs() < 1e-6); // watch 保持最后值
}
}
-170
View File
@@ -1,170 +0,0 @@
//! 守护进程端到端测试:`--mock` 同构栈(配置 → EscBus → 通道 → 仿真电机
//! → 控制循环 → 麦轮运动学),全平台可跑。
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::time::Duration;
use chassisd::config::Config;
use chassisd::controller::{ChassisController, wrap_wheels};
use dji_esc::{CanLink, EscBus, EscBusHandle};
use dji_esc_sim::spawn_plant;
use mecanum::Twist;
use motor_core::MotorFeedback;
use tokio::sync::watch;
use tokio::time::sleep;
/// 搭建与 `chassisd --mock` 完全同构的软件栈。
///
/// `command_timeout_ms` 由调用方指定:稳态测试用大值(模拟持续刷新的
/// 指令源),超时测试用配置原值。
fn stack(config: &Config, command_timeout_ms: u64) -> (EscBusHandle, watch::Sender<Twist>) {
let (bus_side, plant_side) = CanLink::pair(512);
let esc_ids: Vec<u8> = config.motors.iter().map(|m| m.esc_id).collect();
spawn_plant(plant_side, &esc_ids, config.bus_options().period);
let bus = EscBus::spawn(bus_side, config.to_esc_configs(), config.bus_options()).unwrap();
let (twist_tx, twist_rx) = watch::channel(Twist::ZERO);
let motors: [dji_esc::EscMotor; 4] =
bus.motors().to_vec().try_into().unwrap_or_else(|_| unreachable!("exactly 4 motors"));
let gears: [f32; 4] = config
.motors
.iter()
.map(|m| m.gear_ratio)
.collect::<Vec<_>>()
.try_into()
.unwrap_or_else(|_| unreachable!("exactly 4 motors"));
let controller = ChassisController::new(
config.kinematics(),
wrap_wheels(motors, gears),
(config.chassis.max_vx, config.chassis.max_vy, config.chassis.max_omega),
Duration::from_millis(command_timeout_ms),
);
controller.spawn(twist_rx, config.bus_options().period);
(bus, twist_tx)
}
async fn wait_until(total_ms: u64, mut cond: impl FnMut() -> bool) -> bool {
let mut waited = 0u64;
while waited < total_ms {
if cond() {
return true;
}
sleep(Duration::from_millis(5)).await;
waited += 5;
}
cond()
}
fn wheel_rpms(bus: &EscBusHandle) -> [f32; 4] {
bus.motors()
.iter()
.map(|m| m.state().rotor_speed_rpm / m.config().gear_ratio)
.collect::<Vec<_>>()
.try_into()
.unwrap()
}
/// mock 默认底盘(r = 0.0762 m)上 1 m/s 对应的轮转速。
const RPM_PER_MPS: f32 = 60.0 / (std::f32::consts::TAU * 0.0762);
#[tokio::test(start_paused = true)]
async fn forward_command_drives_all_wheels_equally() {
let config = Config::mock_default();
let (bus, twist_tx) = stack(&config, 10_000);
twist_tx.send_replace(Twist { vx: 1.0, ..Twist::ZERO });
let target = RPM_PER_MPS;
let ok = wait_until(3000, || {
wheel_rpms(&bus).iter().all(|&rpm| (rpm - target).abs() < 0.05 * target)
})
.await;
let rpm = wheel_rpms(&bus);
assert!(ok, "未收敛到 {target} rpm: {rpm:?}");
// 停车:指令归零后各轮减速到停。
twist_tx.send_replace(Twist::ZERO);
let stopped = wait_until(2000, || wheel_rpms(&bus).iter().all(|rpm| rpm.abs() < 15.0)).await;
let rpm = wheel_rpms(&bus);
assert!(stopped, "未停车: {rpm:?}");
bus.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn rotation_command_respects_wheel_signs() {
let config = Config::mock_default();
let (bus, twist_tx) = stack(&config, 10_000);
twist_tx.send_replace(Twist { omega: 1.0, ..Twist::ZERO });
// a·ω = (0.2 + 0.25) m/s 的轮线速度。
let target = 0.45 * RPM_PER_MPS;
let ok = wait_until(3000, || {
let rpm = wheel_rpms(&bus);
// 逆时针:左侧(FL、RL)为负,右侧(FR、RR)为正。
[
(rpm[0] + target).abs(),
(rpm[1] - target).abs(),
(rpm[2] + target).abs(),
(rpm[3] - target).abs(),
]
.iter()
.all(|e| *e < 0.05 * target)
})
.await;
let rpm = wheel_rpms(&bus);
assert!(ok, "旋转轮序符号错误, 期望 ±{target}: {rpm:?}");
bus.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn command_timeout_stops_chassis() {
let config = Config::mock_default();
// 指令超时使用配置原值(200ms)。
let (bus, twist_tx) = stack(&config, config.chassis.command_timeout_ms);
// 只发一次指令,随后沉默 → 超过 command_timeout(200ms)后应停车。
twist_tx.send_replace(Twist { vx: 1.0, ..Twist::ZERO });
let moving =
wait_until(1000, || wheel_rpms(&bus).iter().any(|rpm| rpm.abs() > 0.2 * RPM_PER_MPS)).await;
assert!(moving, "指令后底盘应当运动");
sleep(Duration::from_millis(400)).await; // 超过 200ms 超时
let stopped = wait_until(1000, || wheel_rpms(&bus).iter().all(|rpm| rpm.abs() < 15.0)).await;
let rpm = wheel_rpms(&bus);
assert!(stopped, "指令超时后未停车: {rpm:?}");
bus.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn speed_limits_scale_motion() {
let mut config = Config::mock_default();
config.chassis.max_vx = 1.0; // 限速 1 m/s
let (bus, twist_tx) = stack(&config, 10_000);
twist_tx.send_replace(Twist {
vx: 3.0, // 超限指令
vy: 2.0,
omega: 0.0,
});
// 限幅按比例收缩:(3, 2) → (1, 2/3)。四轮 = vx ∓ vy:
// FL/RR = (1 - 2/3) m/s,FR/RL = (1 + 2/3) m/s。
let diag_slow = (1.0 - 2.0 / 3.0) * RPM_PER_MPS;
let diag_fast = (1.0 + 2.0 / 3.0) * RPM_PER_MPS;
let tol = 0.08 * diag_fast;
let ok = wait_until(3000, || {
let rpm = wheel_rpms(&bus);
[
(rpm[0] - diag_slow).abs(),
(rpm[1] - diag_fast).abs(),
(rpm[2] - diag_fast).abs(),
(rpm[3] - diag_slow).abs(),
]
.iter()
.all(|e| *e < tol)
})
.await;
let rpm = wheel_rpms(&bus);
assert!(ok, "限幅后未收敛到 [{diag_slow}, {diag_fast}, {diag_fast}, {diag_slow}] rpm: {rpm:?}");
bus.shutdown().await;
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "delta10-protocol"
description = "DETA10 IMU wire protocol"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
can = { workspace = true }
imu = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
+301
View File
@@ -0,0 +1,301 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! DETA10 IMU 的 CAN 帧编解码(ARCHITECTURE.md §5.2 / 决策点 **D1**)。
//!
//! # 为什么这里没有"实现好"的解码
//!
//! ARCHITECTURE.md §11.4 把「delta10 的 CAN ID 和协议模式」列为**需要人工确认的
//! 决策点 D1**(需查阅数据手册),而文档 §12.2 给出的手册链接
//! `https://fdisystems.github.io/DETA10_Docs/` **已经 404**,厂商现手册为
//! `fdisystems.cn` 上的 PDF。
//!
//! 在没有手册的前提下臆造字节偏移,比留一个空实现更糟:它会以"看起来能用"
//! 的姿态把错误的 roll/pitch/yaw 送进云台闭环。因此本模块:
//!
//! 1. 把**能确定的部分**做实:帧 ID 匹配、DLC 校验、`ImuData` 组装路径;
//! 2. 把**不能确定的部分**参数化为 [`Delta10Layout`](字段偏移 + 标度);
//! 3. 未配置布局时解码返回 [`ImuError::LayoutUnconfirmed`](显式、可测、不会
//! 静默给错数据)。D1 确认后**只需填一组常量**,无需改动任何调用方。
//!
//! # 已知的确定事实
//!
//! - 设备经 CAN 接入,挂在与电机共享的总线上(ARCHITECTURE.md §2.1);
//! - 文档记录了两种协议模式:FDILINK 与「标准」,见 [`Delta10Mode`];
//! - 上层需要的是 [`ImuData`](accel / gyro / mag / roll / pitch / yaw / 时间戳)。
use imu::{ImuData, ImuError};
use thiserror::Error;
use can::CanFrame;
/// 载荷里数值字段的宽度与编码。
///
/// DETA10 的 FDILink 帧用 `float32`,但一个 8 字节 CAN 帧装不下三个 `f32`,
/// 因此真实设备必然是"多帧"或"定点压缩"之一——**这正是 D1 要确认的内容**。
/// 把宽度显式建模出来,D1 确认后只需填一张常量表。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldWidth {
/// 4 字节小端 `f32`。
F32,
/// 2 字节小端 `i16`(定点,配合 `scale`)。
I16,
}
impl FieldWidth {
/// 字节数。
#[must_use]
pub const fn bytes(self) -> usize {
match self {
Self::F32 => 4,
Self::I16 => 2,
}
}
}
/// 载荷里一个数值字段的定义(偏移 + 宽度 + 标度)。
///
/// 有了它,"确认手册后要改什么"就只剩一张表。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FieldLayout {
/// 在 8 字节载荷中的起始偏移。
pub offset: usize,
/// 数值宽度与编码。
pub width: FieldWidth,
/// 原始值 → 物理量的比例(如 0.001 表示原始值单位是 1/1000)。
pub scale: f32,
}
impl FieldLayout {
/// 构造。
#[must_use]
pub const fn new(offset: usize, width: FieldWidth, scale: f32) -> Self {
Self { offset, width, scale }
}
/// 从载荷里取一个字段;越界返回 `None`。
#[must_use]
pub fn read(&self, data: &[u8; 8]) -> Option<f32> {
let end = self.offset.checked_add(self.width.bytes())?;
if end > data.len() {
return None;
}
let raw = match self.width {
FieldWidth::F32 => {
let mut bytes = [0u8; 4];
bytes.copy_from_slice(&data[self.offset..end]);
f32::from_le_bytes(bytes)
}
FieldWidth::I16 => {
let mut bytes = [0u8; 2];
bytes.copy_from_slice(&data[self.offset..end]);
f32::from(i16::from_le_bytes(bytes))
}
};
Some(raw * self.scale)
}
}
/// DETA10 载荷布局(**待 D1 确认**)。
///
/// 单帧 8 字节装不下 9 个浮点量,因此真实设备要么用多帧、要么用定点压缩。
/// 本结构支持"单帧 + 字段子集"的形态;多帧重组待手册确认后按同样思路扩展。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Delta10Layout {
/// 横滚角(度)字段。
pub roll: FieldLayout,
/// 俯仰角(度)字段。
pub pitch: FieldLayout,
/// 偏航角(度)字段。
pub yaw: FieldLayout,
}
/// 协议模式(ARCHITECTURE.md §2.2 记录了两者,先按哪种接需 D1 确认)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Delta10Mode {
/// FDILINK 协议模式。
Fdilink,
/// 标准模式。
Standard,
}
/// DETA10 接入配置。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Delta10Config {
/// 反馈帧的 CAN ID(**待 D1 确认**,默认取 `0x100` 占位)。
pub can_id: u32,
/// 协议模式。
pub mode: Delta10Mode,
/// 载荷布局;`None` 表示尚未确认,解码会返回
/// [`ImuError::LayoutUnconfirmed`]。
pub layout: Option<Delta10Layout>,
}
impl Default for Delta10Config {
fn default() -> Self {
// 0x100 只是占位(ARCHITECTURE.md §10.3 的 robot.toml 里也把它写成
// `imu_can_id = 0x100`,同样标注为待确认)。它**不应**被当作事实使用:
// 布局为 None,因此默认配置下解码一定报 LayoutUnconfirmed。
Self { can_id: 0x100, mode: Delta10Mode::Fdilink, layout: None }
}
}
impl Delta10Config {
/// 换一个 CAN ID(D1 确认后由配置注入)。
#[must_use]
pub const fn with_can_id(mut self, can_id: u32) -> Self {
self.can_id = can_id;
self
}
/// 换协议模式。
#[must_use]
pub const fn with_mode(mut self, mode: Delta10Mode) -> Self {
self.mode = mode;
self
}
/// 装上载荷布局(D1 确认后调用)。
#[must_use]
pub const fn with_layout(mut self, layout: Delta10Layout) -> Self {
self.layout = Some(layout);
self
}
}
/// 解码结果:`Ok(None)` = 不是 IMU 帧(正常情况,总线上大部分帧是电机反馈)。
///
/// # Errors
///
/// - [`ImuError::BadFrame`]:ID 匹配但长度不足;
/// - [`ImuError::LayoutUnconfirmed`]:ID 匹配但布局未确认(D1);
/// - [`ImuError::Checksum`]:字段读取越界(布局与载荷不匹配)。
pub fn try_decode(
config: &Delta10Config,
frame: &CanFrame,
timestamp_ns: u64,
) -> Result<Option<ImuData>, ImuError> {
if frame.id != config.can_id {
return Ok(None);
}
if !frame.is_complete() {
return Err(ImuError::BadFrame);
}
let Some(layout) = config.layout else {
return Err(ImuError::LayoutUnconfirmed);
};
let roll = layout.roll.read(&frame.data).ok_or(ImuError::Checksum)?;
let pitch = layout.pitch.read(&frame.data).ok_or(ImuError::Checksum)?;
let yaw = layout.yaw.read(&frame.data).ok_or(ImuError::Checksum)?;
Ok(Some(ImuData {
roll,
pitch,
yaw,
timestamp_ns,
// 本帧只承载姿态角;加速度 / 角速度需要额外的帧(待手册确认)
..ImuData::default()
}))
}
/// 配置错误:把"不确定"和"确定错"分开。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum Delta10ConfigError {
/// 布局字段越过 8 字节载荷。
#[error("DETA10 字段偏移 {offset} 超出 8 字节载荷")]
FieldOutOfRange {
/// 越界的偏移。
offset: usize,
},
/// 布局非有限标度。
#[error("DETA10 字段标度必须为有限正值")]
NonFiniteScale,
}
/// 校验布局能否落在载荷内(D1 确认后填入常量时用)。
///
/// # Errors
///
/// 字段越界或标度非法时返回错误。
pub fn validate_layout(layout: &Delta10Layout) -> Result<(), Delta10ConfigError> {
for field in [layout.roll, layout.pitch, layout.yaw] {
if !(field.scale.is_finite() && field.scale > 0.0) {
return Err(Delta10ConfigError::NonFiniteScale);
}
if field.offset + field.width.bytes() > 8 {
return Err(Delta10ConfigError::FieldOutOfRange { offset: field.offset });
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// 一个**能装进 8 字节**的合成布局:3 × i16 定点(0.001 度/LSB)。
///
/// 它只用来证明管道可用;真实 DETA10 的偏移/宽度/标度待 D1 确认。
fn fitted_layout() -> Delta10Layout {
Delta10Layout {
roll: FieldLayout::new(0, FieldWidth::I16, 0.001),
pitch: FieldLayout::new(2, FieldWidth::I16, 0.001),
yaw: FieldLayout::new(4, FieldWidth::I16, 0.001),
}
}
#[test]
fn non_imu_frames_are_ignored_not_errors() {
let config = Delta10Config::default().with_can_id(0x100);
let motor = CanFrame::new(0x201, [0; 8]);
assert_eq!(try_decode(&config, &motor, 0), Ok(None));
}
#[test]
fn default_config_refuses_to_guess_the_layout() {
let config = Delta10Config::default().with_can_id(0x100);
let imu_frame = CanFrame::new(0x100, [1, 2, 3, 4, 5, 6, 7, 8]);
assert_eq!(try_decode(&config, &imu_frame, 7), Err(ImuError::LayoutUnconfirmed));
}
#[test]
fn short_imu_frames_are_rejected() {
let config = Delta10Config::default().with_can_id(0x100).with_layout(fitted_layout());
let short = CanFrame::new(0x100, [0; 8]).with_dlc(4);
assert_eq!(try_decode(&config, &short, 0), Err(ImuError::BadFrame));
}
#[test]
fn configured_layout_decodes_attitude_and_stamps_time() {
let config = Delta10Config::default().with_can_id(0x100).with_layout(fitted_layout());
let mut data = [0u8; 8];
// roll = 1.5°, pitch = -2.25°(原始值为毫度)
data[0..2].copy_from_slice(&1500i16.to_le_bytes());
data[2..4].copy_from_slice(&(-2250i16).to_le_bytes());
let frame = CanFrame::new(0x100, data);
let decoded = try_decode(&config, &frame, 42).unwrap().unwrap();
assert!((decoded.roll - 1.5).abs() < 1e-6, "{}", decoded.roll);
assert!((decoded.pitch + 2.25).abs() < 1e-6, "{}", decoded.pitch);
assert_eq!(decoded.timestamp_ns, 42);
assert!(decoded.is_finite());
}
#[test]
fn layout_validation_catches_out_of_range_offsets() {
// offset 6 + 4 字节 f32 = 10 > 8:越界
let bad = Delta10Layout {
roll: FieldLayout::new(0, FieldWidth::F32, 1.0),
pitch: FieldLayout::new(2, FieldWidth::I16, 1.0),
yaw: FieldLayout::new(6, FieldWidth::F32, 1.0),
};
assert!(matches!(
validate_layout(&bad),
Err(Delta10ConfigError::FieldOutOfRange { offset: 6 })
));
let zero_scale = Delta10Layout {
roll: FieldLayout::new(0, FieldWidth::I16, 0.0),
pitch: FieldLayout::new(2, FieldWidth::I16, 1.0),
yaw: FieldLayout::new(4, FieldWidth::I16, 1.0),
};
assert_eq!(validate_layout(&zero_scale), Err(Delta10ConfigError::NonFiniteScale));
assert!(validate_layout(&fitted_layout()).is_ok());
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "delta10"
description = "DETA10 IMU driver"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
can = { workspace = true }
imu = { workspace = true }
delta10-protocol = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
+193
View File
@@ -0,0 +1,193 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! DETA10 IMU 驱动:把总线上的 IMU 帧变成 [`ImuData`](ARCHITECTURE.md §5.3)。
//!
//! 本驱动只负责**收发与筛选**:帧格式与载荷布局的解析在
//! [`motor_protocol::delta10`],那里也记录了为什么载荷布局仍是待确认的决策点 D1。
//! 因此本模块在默认配置下会返回 [`ImuError::LayoutUnconfirmed`]——
//! 这是**有意的**:宁可明确报错,也不把猜出来的姿态角送进云台闭环。
use can::CanBus;
use delta10_protocol::{self as delta10, Delta10Config};
use imu::{Imu, ImuData, ImuError};
fn now_ns() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or_default()
}
/// 挂在任意 [`CanBus`] 上的 DETA10 驱动。
///
/// 与电机共享总线(ARCHITECTURE.md §2.1),因此 [`Delta10Driver::read`] 会
/// **跳过**所有不是 IMU 的帧(总线上大部分帧是电机反馈)。
#[derive(Debug)]
pub struct Delta10Driver<B> {
bus: B,
config: Delta10Config,
}
impl<B> Delta10Driver<B> {
/// 组装。
#[must_use]
pub fn new(bus: B, config: Delta10Config) -> Self {
Self { bus, config }
}
/// 当前配置(D1 确认后由上层换成带布局的配置)。
#[must_use]
pub const fn config(&self) -> &Delta10Config {
&self.config
}
/// 热更新配置(例如运行期从配置中心注入确认后的布局)。
pub fn set_config(&mut self, config: Delta10Config) {
self.config = config;
}
/// 取回底层总线。
#[must_use]
pub fn into_inner(self) -> B {
self.bus
}
}
impl<B: CanBus> Delta10Driver<B> {
/// 阻塞读取下一帧**姿态**数据,跳过非 IMU 帧。
///
/// # Errors
///
/// - 总线关闭 → [`ImuError::Closed`];
/// - 帧 ID 命中但布局未确认 → [`ImuError::LayoutUnconfirmed`](D1);
/// - 帧不合法 → [`ImuError::BadFrame`]。
pub async fn read(&mut self) -> Result<ImuData, ImuError> {
loop {
let frame = self.bus.recv().await.map_err(|_| ImuError::Closed)?;
// 非 IMU 帧(总线上大部分是电机反馈)直接丢弃,继续等下一帧。
if let Some(data) = delta10::try_decode(&self.config, &frame, now_ns())? {
return Ok(data);
}
}
}
/// 非阻塞读取:本轮没有 IMU 帧时返回 `Ok(None)`。
///
/// # Errors
///
/// 同 [`Delta10Driver::read`](只是不会等待)。
pub fn try_read(&mut self) -> Result<Option<ImuData>, ImuError> {
loop {
let frame = match self.bus.try_recv() {
Ok(Some(frame)) => frame,
Ok(None) => return Ok(None),
Err(_) => return Err(ImuError::Closed),
};
if let Some(data) = delta10::try_decode(&self.config, &frame, now_ns())? {
return Ok(Some(data));
}
}
}
}
impl<B: CanBus> Imu for Delta10Driver<B> {
type Error = ImuError;
async fn read(&mut self) -> Result<ImuData, Self::Error> {
Self::read(self).await
}
fn try_read(&mut self) -> Result<Option<ImuData>, Self::Error> {
Self::try_read(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use can::CanFrame;
use delta10_protocol::{Delta10Layout, FieldLayout, FieldWidth};
use can::MockBus;
fn fitted_layout() -> Delta10Layout {
Delta10Layout {
roll: FieldLayout::new(0, FieldWidth::I16, 0.001),
pitch: FieldLayout::new(2, FieldWidth::I16, 0.001),
yaw: FieldLayout::new(4, FieldWidth::I16, 0.001),
}
}
fn imu_frame(roll_mdeg: i16) -> CanFrame {
let mut data = [0u8; 8];
data[0..2].copy_from_slice(&roll_mdeg.to_le_bytes());
CanFrame::new(0x100, data)
}
#[tokio::test]
async fn read_skips_motor_frames_and_returns_attitude() {
let mut bus = MockBus::new();
bus.inject(CanFrame::new(0x201, [0; 8])); // 电机反馈:必须被跳过
bus.inject(CanFrame::new(0x202, [0; 8]));
bus.inject(imu_frame(2500));
let mut driver = Delta10Driver::new(
bus,
Delta10Config::default().with_can_id(0x100).with_layout(fitted_layout()),
);
let data = driver.read().await.unwrap();
assert!((data.roll - 2.5).abs() < 1e-6, "{}", data.roll);
assert!(data.is_finite());
}
#[tokio::test]
async fn default_config_refuses_to_guess() {
let mut bus = MockBus::new();
bus.inject(imu_frame(1000));
let mut driver = Delta10Driver::new(bus, Delta10Config::default().with_can_id(0x100));
assert_eq!(driver.read().await.unwrap_err(), ImuError::LayoutUnconfirmed);
}
#[tokio::test]
async fn try_read_polls_without_blocking() {
let bus = MockBus::new();
let mut driver = Delta10Driver::new(
bus,
Delta10Config::default().with_can_id(0x100).with_layout(fitted_layout()),
);
assert_eq!(driver.try_read().unwrap(), None, "空总线不是错误");
driver.into_inner().inject(imu_frame(-1500));
// 重新组装以取回总线(inject 需要 &mut)。
let mut bus = MockBus::new();
bus.inject(imu_frame(-1500));
let mut driver = Delta10Driver::new(
bus,
Delta10Config::default().with_can_id(0x100).with_layout(fitted_layout()),
);
let data = driver.try_read().unwrap().unwrap();
assert!((data.roll + 1.5).abs() < 1e-6, "{}", data.roll);
}
#[tokio::test]
async fn closed_bus_is_reported_as_closed() {
let mut bus = MockBus::new();
bus.close();
let mut driver = Delta10Driver::new(
bus,
Delta10Config::default().with_can_id(0x100).with_layout(fitted_layout()),
);
assert_eq!(driver.read().await.unwrap_err(), ImuError::Closed);
assert_eq!(driver.try_read().unwrap_err(), ImuError::Closed);
}
#[tokio::test]
async fn works_through_the_imu_trait() {
let mut bus = MockBus::new();
bus.inject(imu_frame(3000));
let mut driver = Delta10Driver::new(
bus,
Delta10Config::default().with_can_id(0x100).with_layout(fitted_layout()),
);
// 经 `Imu` trait 调用(证明驱动满足 L1 的抽象边界)。
let data: ImuData = Imu::read(&mut driver).await.unwrap();
assert!((data.roll - 3.0).abs() < 1e-6);
}
}
-186
View File
@@ -1,186 +0,0 @@
//! 被控对象集成:用同一组参数的**抖动样本**表达模型不确定性。
//!
//! 单点模型整定出来的增益往往对增益 / 时间常数 / 延迟 / 摩擦高度敏感
//! (真车轮子之间、电量与温度变化都会漂移)。整定应针对一个**集成**做
//! 鲁棒优化,而不是针对一条理想曲线做最优化。
use rand::Rng as _;
use rand::SeedableRng as _;
use rand::rngs::StdRng;
use crate::plant::PlantParams;
/// 集成相对基准参数的抖动幅度(比例,0 表示不抖动)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlantTolerances {
/// 空载增益 ± 比例。
pub gain_pct: f32,
/// 时间常数 ± 比例。
pub tau_pct: f32,
/// 命令延迟在 `0..=delay_ticks_max` 拍内均匀取整。
pub delay_ticks_max: u32,
/// 摩擦三件套 ± 比例。
pub friction_pct: f32,
}
impl Default for PlantTolerances {
fn default() -> Self {
Self { gain_pct: 0.2, tau_pct: 0.3, delay_ticks_max: 3, friction_pct: 0.5 }
}
}
/// 一组被控对象参数(成员之间只有参数差异,模型结构相同)。
#[derive(Debug, Clone, PartialEq)]
pub struct PlantEnsemble {
members: Vec<PlantParams>,
}
impl PlantEnsemble {
/// `members` 个完全相同的样本(下限 1)。
#[must_use]
pub fn homogeneous(base: PlantParams, members: usize) -> Self {
Self { members: vec![base; members.max(1)] }
}
/// 以 `base` 为基准做确定性抖动。
///
/// 第 `i` 个成员的随机流由 `seed` 与 `i` 派生——**与线程调度无关**,
/// 因此整定结果可复现。`members` 下限 1(首个成员恰好是基准参数本身,
/// 保证集成里始终包含"名义模型"这一样本)。
#[must_use]
pub fn randomized(
base: PlantParams,
tolerances: PlantTolerances,
members: usize,
seed: u64,
) -> Self {
let members = members.max(1);
let mut out = Vec::with_capacity(members);
out.push(base);
for index in 1..members {
let mut rng = StdRng::seed_from_u64(member_seed(seed, index));
let mut params = base;
params.gain_rpm_per_raw = base.gain_rpm_per_raw * scale(&mut rng, tolerances.gain_pct);
params.tau_s = (base.tau_s * scale(&mut rng, tolerances.tau_pct)).max(1.0e-4);
if tolerances.delay_ticks_max > 0 {
params.delay_ticks = rng.random_range(0..=tolerances.delay_ticks_max);
}
let friction = scale(&mut rng, tolerances.friction_pct);
params.static_raw = base.static_raw * friction;
params.coulomb_raw = base.coulomb_raw * friction;
params.viscous_raw_per_rpm = base.viscous_raw_per_rpm * friction;
out.push(params);
}
Self { members: out }
}
/// 成员数。
#[must_use]
pub fn len(&self) -> usize {
self.members.len()
}
/// 是否为空(构造时已保证 ≥ 1,恒为 `false`)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.members.is_empty()
}
/// 成员参数。
#[must_use]
pub fn members(&self) -> &[PlantParams] {
&self.members
}
/// 逐成员迭代。
pub fn iter(&self) -> std::slice::Iter<'_, PlantParams> {
self.members.iter()
}
}
impl<'a> IntoIterator for &'a PlantEnsemble {
type Item = &'a PlantParams;
type IntoIter = std::slice::Iter<'a, PlantParams>;
fn into_iter(self) -> Self::IntoIter {
self.members.iter()
}
}
/// 成员的随机种子:黄金比例常数散列,避免相邻种子产生相关样本。
fn member_seed(seed: u64, index: usize) -> u64 {
seed ^ (index as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
}
/// 在 `[1 − pct, 1 + pct]` 内均匀取一个比例(`pct < 0` 视为 0)。
fn scale(rng: &mut StdRng, pct: f32) -> f32 {
let pct = pct.max(0.0);
if pct <= 0.0 {
return 1.0;
}
rng.random_range(1.0 - pct..=1.0 + pct)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn homogeneous_repeats_the_same_params() {
let ensemble = PlantEnsemble::homogeneous(PlantParams::gm3508(), 4);
assert_eq!(ensemble.len(), 4);
assert!(ensemble.iter().all(|p| *p == PlantParams::gm3508()));
}
#[test]
fn homogeneous_never_empty() {
assert_eq!(PlantEnsemble::homogeneous(PlantParams::gm3508(), 0).len(), 1);
}
#[test]
fn randomized_is_deterministic_and_includes_nominal() {
let base = PlantParams::gm3508_bench();
let tol = PlantTolerances::default();
let a = PlantEnsemble::randomized(base, tol, 8, 42);
let b = PlantEnsemble::randomized(base, tol, 8, 42);
assert_eq!(a, b, "同 seed 必须逐位一致");
assert_eq!(a.members()[0], base, "首个成员是名义模型");
}
#[test]
fn different_seeds_differ() {
let base = PlantParams::gm3508_bench();
let tol = PlantTolerances::default();
let a = PlantEnsemble::randomized(base, tol, 8, 1);
let b = PlantEnsemble::randomized(base, tol, 8, 2);
assert_ne!(a, b);
}
#[test]
fn jitter_stays_within_tolerances() {
let base = PlantParams::gm3508_bench();
let tol =
PlantTolerances { gain_pct: 0.2, tau_pct: 0.3, delay_ticks_max: 3, friction_pct: 0.5 };
let ensemble = PlantEnsemble::randomized(base, tol, 64, 7);
for member in &ensemble {
let gain_ratio = member.gain_rpm_per_raw / base.gain_rpm_per_raw;
assert!((0.8..=1.2).contains(&gain_ratio), "gain ratio {gain_ratio}");
let tau_ratio = member.tau_s / base.tau_s;
assert!((0.7..=1.3).contains(&tau_ratio), "tau ratio {tau_ratio}");
assert!(member.delay_ticks <= 3);
let friction_ratio = member.coulomb_raw / base.coulomb_raw;
assert!((0.5..=1.5).contains(&friction_ratio), "friction ratio {friction_ratio}");
}
// 抖动确实发生(不是所有成员都等于基准)。
assert!(ensemble.iter().skip(1).any(|p| *p != base));
}
#[test]
fn zero_tolerances_collapse_to_nominal() {
let base = PlantParams::gm3508_bench();
let tol =
PlantTolerances { gain_pct: 0.0, tau_pct: 0.0, delay_ticks_max: 0, friction_pct: 0.0 };
let ensemble = PlantEnsemble::randomized(base, tol, 4, 3);
assert!(ensemble.iter().all(|p| *p == base));
}
}
-258
View File
@@ -1,258 +0,0 @@
//! 离线场景评估:同步、无 I/O、无 async 的闭环重放。
//!
//! 本模块是自动整定的**求值内核**:给定一组速度环增益、一个被控对象与一条
//! 场景,逐拍推进「控制器 → 限幅 → 被控对象」,返回完整 [`Trace`]。
//!
//! 与 `dji-esc` 的 `EscBus` 刻意保持**同一套物理帧语义**(见
//! `tests/harness_parity.rs`):
//!
//! ```text
//! out = velocity_controller.update(setpoint, measured, dt) // 已按 ±current_limit 限幅
//! raw = (out * direction) as i16 // 物理帧(direction = +1)
//! cmd = clamp_current(model, raw.clamp(±current_limit)) // 电调满量程再夹一次
//! ```
//!
//! 单次评估约 10^3~10^4 拍纯浮点运算,因此可以在 `rayon` 下并行搜索
//! 上千组参数——这是"参数空间搜索"能落地的前提。
use dji_esc::{EscModel, clamp_current};
use motor_core::{VelocityController, VelocityGains};
use thiserror::Error;
use crate::ensemble::PlantEnsemble;
use crate::plant::{PlantMotor, PlantParams};
use crate::scenario::Scenario;
use crate::trace::Trace;
/// 闭环限幅与电调型号(决定 effort 的可达范围)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LoopLimits {
/// effort 限幅(raw)——同时是 PID 输出钳位点。
pub current_limit_raw: i16,
/// 电调型号(决定满量程)。
pub model: EscModel,
}
impl LoopLimits {
/// `GM3508 + C620`、限流 12000 raw(≈14.6 A)——与 `EscConfig::gm3508` 一致。
#[must_use]
pub const fn gm3508() -> Self {
Self { current_limit_raw: 12_000, model: EscModel::C620 }
}
}
impl Default for LoopLimits {
fn default() -> Self {
Self::gm3508()
}
}
/// 离线评估失败原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum SimError {
/// 增益含 NaN / inf(`Pid::new` 会 panic,必须在此拦下)。
#[error("gains contain NaN or infinity")]
NonFiniteGains,
/// 场景或限幅退化(`dt <= 0`、总时长为 0、限流为 0 等)。
#[error("degenerate scenario or limits")]
DegenerateScenario,
/// 数值发散(转速超出被控对象量程 100 倍)。
#[error("simulation diverged at step {step}")]
Diverged {
/// 发散发生的采样序号。
step: usize,
},
}
/// 在单台被控对象上重放一条场景。
///
/// # Errors
///
/// 增益非有限、场景退化或数值发散时返回 [`SimError`]——调用方(整定器)
/// 应把它折算成罚值,而不是把非法参数交给控制器。
pub fn simulate(
gains: VelocityGains,
plant: &PlantParams,
scenario: &Scenario,
limits: LoopLimits,
) -> Result<Trace, SimError> {
if !gains.is_finite() {
return Err(SimError::NonFiniteGains);
}
let dt = scenario.dt_s;
let total = scenario.total_s();
let dt_ok = dt.is_finite() && dt > 0.0;
let total_ok = total.is_finite() && total > 0.0;
let target_ok = scenario.target_rpm.is_finite();
if !dt_ok || !total_ok || !target_ok || limits.current_limit_raw <= 0 {
return Err(SimError::DegenerateScenario);
}
let mut controller = VelocityController::from_gains(limits.current_limit_raw, gains);
let mut motor = PlantMotor::new(*plant);
let bound = plant.max_rpm.abs().max(1.0) * 100.0;
let steps = scenario.samples();
let mut trace = Trace::with_capacity(steps);
for step in 0..steps {
let t_s = step as f32 * dt;
let setpoint = scenario.setpoint_at(t_s);
let measured = motor.speed_rpm();
let out = controller.update(setpoint, measured, dt);
// 物理帧:direction = +1(安装方向由驱动层负责翻转)。
let raw = out as i16;
let command = clamp_current(
limits.model,
raw.clamp(-limits.current_limit_raw, limits.current_limit_raw),
);
let saturated = raw.unsigned_abs() >= limits.current_limit_raw.unsigned_abs();
let feedback = motor.step(command, dt);
trace.push(t_s, setpoint, f32::from(feedback.speed_rpm), command, saturated);
if !motor.speed_rpm().is_finite() || motor.speed_rpm().abs() > bound {
return Err(SimError::Diverged { step });
}
}
Ok(trace)
}
/// 在一组被控对象上重放同一条场景(集成鲁棒性评估)。
///
/// 返回 `(成员参数, 结果)` 列表;单个成员的失败不影响其他成员(结果以
/// `Result` 逐项保留,由调用方决定如何聚合)。
#[must_use]
pub fn simulate_ensemble(
gains: VelocityGains,
ensemble: &PlantEnsemble,
scenario: &Scenario,
limits: LoopLimits,
) -> Vec<(PlantParams, Result<Trace, SimError>)> {
ensemble.iter().map(|plant| (*plant, simulate(gains, plant, scenario, limits))).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scenario::SetpointProfile;
const GAINS: VelocityGains = VelocityGains::new(8.0, 2.0, 0.0, 16384.0 / 9254.0);
const STEP: Scenario = Scenario::step(3000.0, 1.0);
#[test]
fn ideal_plant_tracks_step() {
let trace = simulate(GAINS, &PlantParams::gm3508(), &STEP, LoopLimits::gm3508()).unwrap();
assert!(trace.is_consistent());
assert_eq!(trace.len(), 1000);
let tail = trace.window(0.5, 1.0);
assert!((tail.mean_rpm() - 3000.0).abs() < 30.0, "steady state = {}", tail.mean_rpm());
}
#[test]
fn non_finite_gains_are_rejected_not_panicking() {
let bad = VelocityGains::new(f32::NAN, 0.0, 0.0, 0.0);
assert_eq!(
simulate(bad, &PlantParams::gm3508(), &STEP, LoopLimits::gm3508()).err(),
Some(SimError::NonFiniteGains)
);
let infinite = VelocityGains::new(1.0, f32::INFINITY, 0.0, 0.0);
assert_eq!(
simulate(infinite, &PlantParams::gm3508(), &STEP, LoopLimits::gm3508()).err(),
Some(SimError::NonFiniteGains)
);
}
#[test]
fn degenerate_scenario_is_rejected() {
let zero_limit = LoopLimits { current_limit_raw: 0, model: EscModel::C620 };
assert_eq!(
simulate(GAINS, &PlantParams::gm3508(), &STEP, zero_limit).err(),
Some(SimError::DegenerateScenario)
);
let zero_dt = STEP.with_dt_s(0.0);
assert_eq!(
simulate(GAINS, &PlantParams::gm3508(), &zero_dt, LoopLimits::gm3508()).err(),
Some(SimError::DegenerateScenario)
);
}
#[test]
fn huge_gains_diverge_and_are_reported() {
let aggressive = VelocityGains::new(1.0e6, 1.0e6, 0.0, 0.0);
let long = Scenario::step(3000.0, 5.0);
let result = simulate(aggressive, &PlantParams::gm3508(), &long, LoopLimits::gm3508());
match result {
Err(SimError::Diverged { .. }) => {}
Err(SimError::NonFiniteGains | SimError::DegenerateScenario) => {
panic!("expected divergence")
}
Ok(trace) => {
// 有限但剧烈振荡:至少必须反映在饱和与高转速上。
assert!(trace.peak_rpm() > 3000.0);
assert!(trace.saturated_fraction() > 0.0);
}
}
}
#[test]
fn output_respects_current_limit() {
let limits = LoopLimits { current_limit_raw: 5_000, model: EscModel::C620 };
let trace = simulate(
VelocityGains::new(50.0, 10.0, 0.0, 0.0),
&PlantParams::gm3508(),
&STEP,
limits,
)
.unwrap();
assert!(
trace.effort_raw.iter().all(|raw| raw.abs() <= 5_000),
"effort must stay inside the limit"
);
assert!(trace.saturated_fraction() > 0.0, "大误差下应当饱和");
}
#[test]
fn c610_full_scale_clamps_below_configured_limit() {
let limits = LoopLimits { current_limit_raw: 12_000, model: EscModel::C610 };
let trace = simulate(
VelocityGains::new(1000.0, 0.0, 0.0, 0.0),
&PlantParams::gm3508(),
&Scenario::step(9000.0, 0.2),
limits,
)
.unwrap();
assert!(trace.effort_raw.iter().all(|raw| raw.abs() <= EscModel::C610.max_current_raw()));
}
#[test]
fn ensemble_runs_every_member() {
let ensemble = PlantEnsemble::homogeneous(PlantParams::gm3508(), 3);
let results = simulate_ensemble(GAINS, &ensemble, &STEP, LoopLimits::gm3508());
assert_eq!(results.len(), 3);
assert!(results.iter().all(|(_, result)| result.is_ok()));
}
#[test]
fn updown_profile_returns_to_rest() {
let scenario =
Scenario::ramp_and_hold(2000.0, 0.2, 0.3).with_profile(SetpointProfile::UpDown);
let trace =
simulate(GAINS, &PlantParams::gm3508(), &scenario, LoopLimits::gm3508()).unwrap();
let last = trace.rpm.last().copied().unwrap_or_default();
assert!(last.abs() < 100.0, "final rpm = {last}");
}
#[test]
fn friction_plant_needs_more_effort_than_ideal() {
let scenario = Scenario::ramp_and_hold(1000.0, 0.2, 1.0);
let ideal =
simulate(GAINS, &PlantParams::gm3508(), &scenario, LoopLimits::gm3508()).unwrap();
let bench =
simulate(GAINS, &PlantParams::gm3508_bench(), &scenario, LoopLimits::gm3508()).unwrap();
assert!(
bench.window(0.6, 1.2).mean_rpm() <= ideal.window(0.6, 1.2).mean_rpm(),
"摩擦 + 延迟只会拖慢跟随"
);
}
}
-39
View File
@@ -1,39 +0,0 @@
//! DJI 电调被控对象仿真:模型、场景与**离线评估 harness**。
//!
//! 本 crate 从 `dji-esc` 剥离而来,只负责"电调对面的那台电机"以及如何对它
//! 做实验,不碰任何真实 I/O——总线接口是一对 `CanLink` 通道。
//!
//! # 模块职责
//!
//! - [`plant`]:被控对象模型(一阶转速 + 静/库仑/黏滞摩擦 + 命令延迟 + 负载);
//! - [`ensemble`]:参数集成(域随机化),表达模型不确定性;
//! - [`scenario`]:设定值曲线与采样节拍(阶跃 / 斜坡 / 阶梯 / 升降);
//! - [`trace`]:试验轨迹(设定值、转速、effort、饱和标志);
//! - [`harness`]:**同步**闭环重放——自动整定的求值内核(纯 CPU、可并行);
//! - [`rig`]:**异步**假电调组,挂在 [`CanLink`] 上供 `EscBus` 联调。
//!
//! # 两条路径为什么都要有
//!
//! - `rig` 用于端到端联调(`chassisd --mock`、闭环集成测试),语义是
//! "挂在总线上的假电调";
//! - `harness` 用于参数搜索,语义是"一条场景的数值积分",单次评估只有
//! 浮点运算、没有 tokio 任务与通道,因此可以在 `rayon` 下并行跑上千组
//! 参数。两者的物理帧语义由 `tests/harness_parity.rs` 对齐。
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub mod ensemble;
pub mod harness;
pub mod plant;
pub mod rig;
pub mod scenario;
pub mod trace;
pub use ensemble::{PlantEnsemble, PlantTolerances};
pub use harness::{LoopLimits, SimError, simulate, simulate_ensemble};
pub use plant::{PlantMotor, PlantParams};
pub use rig::{spawn_plant, spawn_plant_with};
pub use scenario::{Scenario, SetpointProfile};
pub use trace::{Trace, Window};
/// 复用的链路类型(`dji-esc` 的协议层与假电调通过它对接)。
pub use can_link::{CanFrame, CanLink};
-361
View File
@@ -1,361 +0,0 @@
//! 被控对象模型:一阶转速 + 摩擦 / 延迟 / 负载。
//!
//! 电调坐标系(正电流 → 正转速),逐拍离散(`dt` 秒):
//!
//! 1. **命令延迟**:`current_raw` 进 FIFO,`delay_ticks` 拍后才真正作用(模拟
//! 链路 + 采样延迟;`0` 表示立即作用)。
//! 2. **摩擦**:先由 `u` 与当前运动状态算出净转矩输入
//! (静摩擦死区 → 库仑摩擦 → 黏滞阻尼,见 [`PlantMotor::step`])。
//! 3. **一阶转速**:`rpm_target = clamp(net × gain, ±max_rpm) − load_rpm`,
//! 转速以时间常数 `tau` 一阶趋近(`alpha = 1 − exp(−dt/tau)`)。
//! 4. **编码器积分与量化**:机械角按转速积分并对 8192 取模;反馈帧是
//! `EscFeedback`,因此角度被截断为 `u16`、转速被截断为 `i16`——量化是
//! 协议类型自带的,不需要额外开关。
//!
//! [`PlantParams::gm3508`] 复现最初的**无摩擦一阶模型**(`chassisd --mock`
//! 与闭环测试依赖它,行为逐位不变);[`PlantParams::gm3508_bench`] 是带摩擦
//! 与延迟的**台架量级**预设,供自动整定使用。
use std::collections::VecDeque;
use dji_esc::{ENCODER_TICKS, EscFeedback};
/// 转速绝对值低于该值(rpm)视为静止——静摩擦判别用。
const STICK_RPM_EPS: f32 = 1.0;
/// 被控对象参数(电调坐标系)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlantParams {
/// 空载增益(rpm / raw):`GM3508` 实测约 0.565(≈ 9254 / 16384)。
pub gain_rpm_per_raw: f32,
/// 机电时间常数(秒)。
pub tau_s: f32,
/// 转速上限(rpm)。
pub max_rpm: f32,
/// 静摩擦阈值(raw):静止且 |u| ≤ 该值时不产生任何运动(stick)。
pub static_raw: f32,
/// 库仑摩擦(raw):与运动方向相反的恒定阻力。
pub coulomb_raw: f32,
/// 黏滞阻尼(raw / rpm)。
pub viscous_raw_per_rpm: f32,
/// 命令传输延迟(拍,`dt` 的整数倍)。
pub delay_ticks: u32,
/// 恒定外载(折算成 rpm,直接被目标转速减去)。
pub load_rpm: f32,
}
impl PlantParams {
/// 无摩擦、无延迟的理想一阶模型(与历史 `PlantMotor::gm3508` 逐位一致)。
#[must_use]
pub const fn gm3508() -> Self {
Self {
gain_rpm_per_raw: 9254.0 / 16384.0,
tau_s: 0.12,
max_rpm: 9254.0,
static_raw: 0.0,
coulomb_raw: 0.0,
viscous_raw_per_rpm: 0.0,
delay_ticks: 0,
load_rpm: 0.0,
}
}
/// 台架量级预设:在 [`Self::gm3508`] 上叠加静摩擦 / 库仑摩擦 / 黏滞阻尼
/// 与 2 拍命令延迟。
///
/// 摩擦量级取自 `docs/architecture.md` 与台架记录的空载实测(起动需
/// 400~600 raw),**是量级估计而非辨识结果**;需要更贴合实车时可用
/// [`Self::with_friction`] / [`Self::with_delay_ticks`] 覆盖。
#[must_use]
pub const fn gm3508_bench() -> Self {
let mut params = Self::gm3508();
params.static_raw = 450.0;
params.coulomb_raw = 500.0;
params.viscous_raw_per_rpm = 0.05;
params.delay_ticks = 2;
params
}
/// 设置摩擦三件套(静摩擦阈值 / 库仑 / 黏滞)。
#[must_use]
pub const fn with_friction(
mut self,
static_raw: f32,
coulomb_raw: f32,
viscous_raw_per_rpm: f32,
) -> Self {
self.static_raw = static_raw;
self.coulomb_raw = coulomb_raw;
self.viscous_raw_per_rpm = viscous_raw_per_rpm;
self
}
/// 设置命令延迟(拍)。
#[must_use]
pub const fn with_delay_ticks(mut self, ticks: u32) -> Self {
self.delay_ticks = ticks;
self
}
/// 设置恒定外载(rpm)。
#[must_use]
pub const fn with_load_rpm(mut self, rpm: f32) -> Self {
self.load_rpm = rpm;
self
}
}
/// 单台电机被控对象。`Clone` 便于在同一组参数上重放多条场景。
#[derive(Debug, Clone)]
pub struct PlantMotor {
params: PlantParams,
speed_rpm: f32,
angle_raw: f32,
/// 命令延迟 FIFO(`delay_ticks == 0` 时恒为空)。
delay_line: VecDeque<f32>,
}
impl PlantMotor {
/// 用给定参数创建静止的电机。
#[must_use]
pub fn new(params: PlantParams) -> Self {
Self { params, speed_rpm: 0.0, angle_raw: 0.0, delay_line: VecDeque::new() }
}
/// 理想 `GM3508`(无摩擦、无延迟)——历史默认,`--mock` 与闭环测试沿用。
#[must_use]
pub fn gm3508() -> Self {
Self::new(PlantParams::gm3508())
}
/// 当前参数。
#[must_use]
pub const fn params(&self) -> &PlantParams {
&self.params
}
/// 当前转子转速(rpm,电调侧)。
#[must_use]
pub const fn speed_rpm(&self) -> f32 {
self.speed_rpm
}
/// 当前机械角(编码器计数,`0..8192`)。
#[must_use]
pub const fn angle_raw(&self) -> f32 {
self.angle_raw
}
/// 回到静止初态(保留参数)。
pub fn reset(&mut self) {
self.speed_rpm = 0.0;
self.angle_raw = 0.0;
self.delay_line.clear();
}
/// 推进一个时间步,返回本步反馈帧内容。
///
/// 摩擦模型(`net` 即折算到电流量纲的净驱动量):
///
/// - 运动时(`|rpm| > 1`):`net = u − sign(rpm)·coulomb − viscous·rpm`;
/// - 静止但 `|u| > static`:`net = u − sign(u)·coulomb`(突破静摩擦);
/// - 静止且 `|u| ≤ static`:`net = 0`(粘住)。
///
/// `static = coulomb = viscous = 0` 时三个分支都退化为 `net = u`,
/// 即历史的一阶模型。
pub fn step(&mut self, current_raw: i16, dt: f32) -> EscFeedback {
let applied = self.apply_delay(f32::from(current_raw));
let net = self.net_torque_raw(applied);
let target = (net * self.params.gain_rpm_per_raw)
.clamp(-self.params.max_rpm, self.params.max_rpm)
- self.params.load_rpm;
if self.params.tau_s > 0.0 && dt.is_finite() && dt > 0.0 {
let alpha = 1.0 - (-dt / self.params.tau_s).exp();
self.speed_rpm += (target - self.speed_rpm) * alpha;
} else {
// 非正时间常数 = 无惯性:立即到位(防御非法 dt)。
self.speed_rpm = target;
}
let ticks = f32::from(ENCODER_TICKS);
self.angle_raw = (self.angle_raw + self.speed_rpm / 60.0 * ticks * dt).rem_euclid(ticks);
EscFeedback {
angle_raw: self.angle_raw as u16,
speed_rpm: self.speed_rpm as i16,
current_raw,
temperature_c: 30,
}
}
/// 命令延迟:返回 `delay_ticks` 拍之前写入的命令(未填满时返回 0)。
fn apply_delay(&mut self, u: f32) -> f32 {
let depth = self.params.delay_ticks as usize;
if depth == 0 {
return u;
}
self.delay_line.push_back(u);
while self.delay_line.len() > depth {
if let Some(delayed) = self.delay_line.pop_front() {
return delayed;
}
}
0.0
}
/// 折算到电流量纲的净驱动量(含静/库仑/黏滞摩擦)。
fn net_torque_raw(&self, u: f32) -> f32 {
let (static_raw, coulomb_raw, viscous) =
(self.params.static_raw, self.params.coulomb_raw, self.params.viscous_raw_per_rpm);
if self.speed_rpm.abs() > STICK_RPM_EPS {
let dir = if self.speed_rpm > 0.0 { 1.0 } else { -1.0 };
u - dir * coulomb_raw - viscous * self.speed_rpm
} else if u.abs() > static_raw {
let dir = if u > 0.0 { 1.0 } else { -1.0 };
u - dir * coulomb_raw
} else {
0.0
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const DT: f32 = 0.001;
/// 浮点断言辅助:避免 `float_cmp`,同时给出可读的失败信息。
#[track_caller]
fn assert_close(actual: f32, expected: f32, what: &str) {
assert!((actual - expected).abs() < 1e-3, "{what}: actual {actual}, expected {expected}");
}
#[test]
fn ideal_model_matches_legacy_first_order_response() {
// 与历史实现(无摩擦一阶模型)逐位一致的黄金值。
let mut plant = PlantMotor::gm3508();
let gain = 9254.0 / 16384.0;
let mut expected = 0.0f32;
for _ in 0..50 {
let target = (10_000f32 * gain).clamp(-9254.0, 9254.0);
let alpha = 1.0 - (-DT / 0.12).exp();
expected += (target - expected) * alpha;
let fb = plant.step(10_000, DT);
assert_eq!(fb.speed_rpm, expected as i16, "legacy step mismatch");
}
// 50 拍(50 ms)只走完约 34% 的一阶响应 → 约 1925 rpm(远未到稳态)。
// 精确数值由上面的逐拍比对保证,这里只锁定量级。
assert!((1900.0..1950.0).contains(&plant.speed_rpm()), "rpm = {}", plant.speed_rpm());
}
#[test]
fn zero_current_stays_still() {
let mut plant = PlantMotor::gm3508();
for _ in 0..100 {
let fb = plant.step(0, DT);
assert_eq!(fb.speed_rpm, 0);
}
assert!((plant.angle_raw()).abs() < f32::EPSILON);
}
#[test]
fn static_friction_holds_below_threshold() {
let params = PlantParams::gm3508().with_friction(450.0, 500.0, 0.0);
let mut plant = PlantMotor::new(params);
for _ in 0..1000 {
plant.step(400, DT);
}
assert_close(plant.speed_rpm(), 0.0, "低于静摩擦阈值的电流不得产生运动");
}
#[test]
fn breakaway_above_static_threshold_moves() {
let params = PlantParams::gm3508().with_friction(450.0, 500.0, 0.0);
let mut plant = PlantMotor::new(params);
for _ in 0..1000 {
plant.step(1000, DT);
}
// 净驱动 = 1000 − 500 = 500 raw → 稳态 ≈ 500 × 0.565 = 282 rpm。
let expected = 500.0 * (9254.0 / 16384.0);
assert!(
(plant.speed_rpm() - expected).abs() < 5.0,
"rpm = {} expected ≈ {expected}",
plant.speed_rpm()
);
}
#[test]
fn coulomb_friction_opposes_motion_in_both_directions() {
let params = PlantParams::gm3508().with_friction(0.0, 500.0, 0.0);
let mut forward = PlantMotor::new(params);
let mut backward = PlantMotor::new(params);
for _ in 0..2000 {
forward.step(2000, DT);
backward.step(-2000, DT);
}
assert!(forward.speed_rpm() > 0.0);
assert!(backward.speed_rpm() < 0.0);
assert!((forward.speed_rpm() + backward.speed_rpm()).abs() < 1.0, "对称性");
}
#[test]
fn delay_shifts_response_by_exact_ticks() {
let immediate = PlantParams::gm3508();
let delayed = PlantParams::gm3508().with_delay_ticks(3);
let mut a = PlantMotor::new(immediate);
let mut b = PlantMotor::new(delayed);
let mut a_trace = Vec::new();
let mut b_trace = Vec::new();
for _ in 0..100 {
a_trace.push(a.step(6000, DT).speed_rpm);
b_trace.push(b.step(6000, DT).speed_rpm);
}
// 延迟 3 拍:前 3 拍反馈恒为 0,之后 b[i] == a[i-3]。
assert_eq!(&b_trace[..3], &[0, 0, 0]);
for i in 3..100 {
assert_eq!(b_trace[i], a_trace[i - 3], "tick {i}");
}
}
#[test]
fn load_rpm_offsets_steady_state() {
let unloaded = PlantParams::gm3508();
let loaded = unloaded.with_load_rpm(200.0);
let mut a = PlantMotor::new(unloaded);
let mut b = PlantMotor::new(loaded);
for _ in 0..5000 {
a.step(16384, DT);
b.step(16384, DT);
}
assert!((a.speed_rpm() - 9254.0).abs() < 0.1, "空载稳态应顶到上限: {}", a.speed_rpm());
assert!(
(b.speed_rpm() - 9054.0).abs() < 0.1,
"外载直接把稳态拉低 200 rpm: {}",
b.speed_rpm()
);
}
#[test]
fn feedback_is_quantized_by_protocol_types() {
let mut plant = PlantMotor::gm3508();
for _ in 0..10_000 {
let fb = plant.step(9000, DT);
assert!(fb.angle_raw < 8192, "angle must stay inside one revolution");
}
let fb = plant.step(9000, DT);
// 内部转速是浮点,反馈按协议被截断为 i16——量化由类型自带。
assert_eq!(fb.speed_rpm, plant.speed_rpm() as i16);
assert_eq!(fb.temperature_c, 30);
}
#[test]
fn reset_returns_to_rest() {
let mut plant = PlantMotor::gm3508();
for _ in 0..100 {
plant.step(8000, DT);
}
assert!(plant.speed_rpm() > 0.0);
plant.reset();
assert_close(plant.speed_rpm(), 0.0, "复位后转速归零");
assert_close(plant.angle_raw(), 0.0, "复位后角度归零");
}
}
-147
View File
@@ -1,147 +0,0 @@
//! 异步 plant rig:把被控对象挂到一对 [`CanLink`] 通道上。
//!
//! 与真实电调对调位置的"假电调":消费命令帧(`0x200` / `0x1FF`),按
//! `tick` 节拍产出反馈帧。`EscBus` 完全看不出区别,因此
//! **协议 → 驱动 → 控制回路**的整条链路都能在无硬件环境下跑通
//! (`chassisd --mock`、闭环集成测试)。
use std::collections::HashMap;
use std::time::Duration;
use dji_esc::{
COMMAND_FRAME_ID_PRIMARY, COMMAND_FRAME_ID_SECONDARY, CanFrame, CanLink, encode_feedback_frame,
};
use tokio::task::JoinHandle;
use tokio::time::{MissedTickBehavior, interval};
use tracing::trace;
use crate::plant::{PlantMotor, PlantParams};
/// 用理想 `GM3508` 模型启动指定电调 ID 的假电调组。
///
/// 返回任务句柄,测试结束或退出演示时 `abort()` 即可(丢弃句柄 = 让任务独立运行)。
pub fn spawn_plant(link: CanLink, esc_ids: &[u8], tick: Duration) -> JoinHandle<()> {
let specs = esc_ids.iter().map(|&id| (id, PlantParams::gm3508()));
spawn_plant_with(link, specs, tick)
}
/// 用逐电调的参数启动假电调组(异构轮子 / 域随机化场景)。
pub fn spawn_plant_with(
mut link: CanLink,
specs: impl IntoIterator<Item = (u8, PlantParams)>,
tick: Duration,
) -> JoinHandle<()> {
let specs: Vec<(u8, PlantParams)> = specs.into_iter().collect();
let mut motors: HashMap<u8, PlantMotor> =
specs.iter().map(|&(id, params)| (id, PlantMotor::new(params))).collect();
let mut commands: HashMap<u8, i16> = specs.iter().map(|&(id, _)| (id, 0)).collect();
let dt = tick.as_secs_f32();
let mut ticker = interval(tick);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
tokio::spawn(async move {
loop {
tokio::select! {
frame = link.incoming_rx.recv() => match frame {
Some(frame) => apply_command_frame(&frame, &mut commands),
None => break,
},
_ = ticker.tick() => {
for (&id, motor) in &mut motors {
let cmd = commands.get(&id).copied().unwrap_or(0);
let feedback = motor.step(cmd, dt);
if link
.outgoing_tx
.send(encode_feedback_frame(id, &feedback))
.await
.is_err()
{
return;
}
}
}
}
}
trace!("plant exiting");
})
}
/// 把一帧组控制帧拆到各电调的当前命令上(非命令帧忽略)。
fn apply_command_frame(frame: &CanFrame, commands: &mut HashMap<u8, i16>) {
let group_start = match frame.id {
COMMAND_FRAME_ID_PRIMARY => 1u8,
COMMAND_FRAME_ID_SECONDARY => 5u8,
_ => return,
};
for slot in 0..4u8 {
if let Some(cmd) = commands.get_mut(&(group_start + slot)) {
*cmd = i16::from_be_bytes([
frame.data[(slot * 2) as usize],
frame.data[(slot * 2 + 1) as usize],
]);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use dji_esc::{COMMAND_FRAME_ID_PRIMARY, decode_feedback};
use tokio::time::timeout;
#[test]
fn command_frame_splits_into_slots() {
let mut commands: HashMap<u8, i16> = (1..=8).map(|id| (id, 0)).collect();
let frame = CanFrame::new(COMMAND_FRAME_ID_PRIMARY, [0x04, 0xD2, 0xFD, 0xC9, 0, 0, 0, 0]);
apply_command_frame(&frame, &mut commands);
assert_eq!(commands[&1], 1234);
assert_eq!(commands[&2], -567);
assert_eq!(commands[&3], 0);
// 副组帧只影响电调 5–8:槽位 2 → 电调 7,数据取自字节 4..6。
let secondary = CanFrame::new(COMMAND_FRAME_ID_SECONDARY, [0, 0, 0, 0, 0, 1, 0, 0]);
apply_command_frame(&secondary, &mut commands);
assert_eq!(commands[&5], 0);
assert_eq!(commands[&7], 1);
assert_eq!(commands[&1], 1234, "副组帧不得影响主组");
// 非命令帧被忽略。
apply_command_frame(&CanFrame::new(0x999, [0xFF; 8]), &mut commands);
assert_eq!(commands[&1], 1234);
}
#[tokio::test(start_paused = true)]
async fn plant_answers_with_moving_feedback() {
let (mut bus_side, plant_side) = CanLink::pair(64);
let plant = spawn_plant(plant_side, &[1], Duration::from_millis(1));
// 下发 10000 raw,一阶模型必然转起来。
let command = CanFrame::new(COMMAND_FRAME_ID_PRIMARY, [0x27, 0x10, 0, 0, 0, 0, 0, 0]);
bus_side.outgoing_tx.send(command).await.expect("link open");
let mut saw_positive = false;
for _ in 0..20 {
let frame = timeout(Duration::from_millis(50), bus_side.incoming_rx.recv())
.await
.expect("plant must keep emitting feedback")
.expect("link open");
let (id, feedback) = decode_feedback(&frame).expect("feedback frame");
assert_eq!(id, 1);
saw_positive |= feedback.speed_rpm > 0;
}
assert!(saw_positive, "假电调必须按命令转起来");
plant.abort();
}
#[tokio::test(start_paused = true)]
async fn plant_with_custom_params_emits_feedback_during_delay() {
let (mut bus_side, plant_side) = CanLink::pair(64);
let specs = vec![(2u8, PlantParams::gm3508_bench())];
let plant = spawn_plant_with(plant_side, specs, Duration::from_millis(1));
let frame = timeout(Duration::from_millis(50), bus_side.incoming_rx.recv())
.await
.expect("延迟期间也应持续产出反馈帧")
.expect("link open");
let (id, feedback) = decode_feedback(&frame).expect("feedback frame");
assert_eq!(id, 2);
assert_eq!(feedback.speed_rpm, 0);
plant.abort();
}
}
-236
View File
@@ -1,236 +0,0 @@
//! 场景定义:设定值曲线 + 采样节拍。
//!
//! 场景与具体被控对象解耦:同一场景可在 [`crate::PlantMotor`](离线)、
//! `CanLink` 上的 `EscBus` 闭环(在线)与真机台架(`chassis-bench`)上重放,
//! 因此整定结果可以跨后端比较。
/// 设定值曲线。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SetpointProfile {
/// 阶跃:`t = 0` 直接给目标(忽略 `ramp_s`)。
Step,
/// 斜坡 + 保持:`ramp_s` 内线性升到目标,随后保持 `hold_s`。
RampAndHold,
/// 阶梯:在 `ramp_s + hold_s` 内均分 `levels` 级,逐级跳变到目标。
Staircase {
/// 阶梯数(≥ 1)。
levels: u32,
},
/// 升 → 保持 → 降 → 保持(回零),总时长 `2 × (ramp_s + hold_s)`。
UpDown,
}
/// 一次试验的设定值与时间规格。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Scenario {
/// 控制 / 采样节拍(秒)。
pub dt_s: f32,
/// 斜坡时间(秒);`0` 表示瞬时。
pub ramp_s: f32,
/// 保持时间(秒)。
pub hold_s: f32,
/// 目标转子转速(rpm)。
pub target_rpm: f32,
/// 设定值曲线。
pub profile: SetpointProfile,
}
impl Scenario {
/// 默认控制节拍(1 kHz,与 C620 命令周期一致)。
pub const DEFAULT_DT_S: f32 = 0.001;
/// 阶跃 + 保持。
#[must_use]
pub const fn step(target_rpm: f32, hold_s: f32) -> Self {
Self {
dt_s: Self::DEFAULT_DT_S,
ramp_s: 0.0,
hold_s,
target_rpm,
profile: SetpointProfile::Step,
}
}
/// 斜坡 + 保持(台架默认整定场景)。
#[must_use]
pub const fn ramp_and_hold(target_rpm: f32, ramp_s: f32, hold_s: f32) -> Self {
Self {
dt_s: Self::DEFAULT_DT_S,
ramp_s,
hold_s,
target_rpm,
profile: SetpointProfile::RampAndHold,
}
}
/// 换一个采样节拍。
#[must_use]
pub const fn with_dt_s(mut self, dt_s: f32) -> Self {
self.dt_s = dt_s;
self
}
/// 换一条设定值曲线。
#[must_use]
pub const fn with_profile(mut self, profile: SetpointProfile) -> Self {
self.profile = profile;
self
}
/// 场景总时长(秒)。
#[must_use]
pub fn total_s(&self) -> f32 {
match self.profile {
SetpointProfile::Step => self.hold_s,
SetpointProfile::RampAndHold | SetpointProfile::Staircase { .. } => {
self.ramp_s + self.hold_s
}
SetpointProfile::UpDown => 2.0 * (self.ramp_s + self.hold_s),
}
}
/// 场景采样点数(`dt_s` 非法时返回 1,由调用方做参数校验)。
#[must_use]
pub fn samples(&self) -> usize {
let total = self.total_s();
let dt_ok = self.dt_s.is_finite() && self.dt_s > 0.0;
let total_ok = total.is_finite() && total > 0.0;
if !dt_ok || !total_ok {
return 1;
}
((total / self.dt_s).round() as usize).max(1)
}
/// `t_s` 时刻的设定值(rpm)。
#[must_use]
pub fn setpoint_at(&self, t_s: f32) -> f32 {
let target = self.target_rpm;
let ramp = self.ramp_s;
match self.profile {
SetpointProfile::Step => target,
SetpointProfile::RampAndHold => {
if ramp > 0.0 && t_s < ramp {
target * (t_s / ramp)
} else {
target
}
}
SetpointProfile::Staircase { levels } => {
let levels = levels.max(1);
let total = ramp + self.hold_s;
if total <= 0.0 {
return target;
}
let rung_s = total / levels as f32;
let index = (t_s / rung_s).floor().max(0.0) as u32;
target * ((index.min(levels - 1) + 1) as f32 / levels as f32)
}
SetpointProfile::UpDown => {
let up_end = ramp;
let hold_end = up_end + self.hold_s;
let down_end = hold_end + ramp;
if t_s < up_end {
if ramp > 0.0 { target * (t_s / ramp) } else { target }
} else if t_s < hold_end {
target
} else if t_s < down_end {
let progress = (t_s - hold_end) / ramp;
target * (1.0 - progress)
} else {
0.0
}
}
}
}
/// 稳态评价窗口的起点(秒):斜坡 / 阶梯结束、进入最后一段保持时。
#[must_use]
pub fn steady_start_s(&self) -> f32 {
match self.profile {
SetpointProfile::Step => 0.0,
SetpointProfile::RampAndHold => self.ramp_s,
SetpointProfile::Staircase { levels } => {
let level_s = self.level_s(levels);
((levels.max(1) - 1) as f32) * level_s
}
SetpointProfile::UpDown => self.ramp_s + self.hold_s,
}
}
/// 稳态评价窗口的终点(秒)。
#[must_use]
pub fn steady_end_s(&self) -> f32 {
match self.profile {
// 升降场景只评价上升段后的保持段。
SetpointProfile::UpDown => self.ramp_s + self.hold_s,
_ => self.total_s(),
}
}
/// 阶梯的单级时长(秒)。
fn level_s(&self, levels: u32) -> f32 {
let levels = levels.max(1);
let total = self.ramp_s + self.hold_s;
if total > 0.0 { total / levels as f32 } else { 0.0 }
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 浮点断言辅助:避免 `float_cmp`,同时给出可读的失败信息。
#[track_caller]
fn assert_close(actual: f32, expected: f32) {
assert!((actual - expected).abs() < 1e-3, "actual {actual}, expected {expected}");
}
#[test]
fn step_is_immediate_and_lasts_hold() {
let s = Scenario::step(1000.0, 0.5);
assert!((s.total_s() - 0.5).abs() < 1e-6);
assert_close(s.setpoint_at(0.0), 1000.0);
assert_close(s.setpoint_at(0.4), 1000.0);
assert_eq!(s.samples(), 500);
}
#[test]
fn ramp_is_linear_then_holds() {
let s = Scenario::ramp_and_hold(1000.0, 0.2, 1.0);
assert_close(s.setpoint_at(0.0), 0.0);
assert!((s.setpoint_at(0.1) - 500.0).abs() < 1e-3);
assert_close(s.setpoint_at(0.2), 1000.0);
assert_close(s.setpoint_at(1.1), 1000.0);
assert!((s.total_s() - 1.2).abs() < 1e-6);
}
#[test]
fn staircase_rises_in_equal_levels() {
let s = Scenario::ramp_and_hold(1000.0, 0.0, 1.0)
.with_profile(SetpointProfile::Staircase { levels: 4 });
assert!((s.setpoint_at(0.0) - 250.0).abs() < 1e-3);
assert!((s.setpoint_at(0.3) - 500.0).abs() < 1e-3);
assert!((s.setpoint_at(0.6) - 750.0).abs() < 1e-3);
assert!((s.setpoint_at(0.99) - 1000.0).abs() < 1e-3);
// 越界采样被夹在最后一级。
assert_close(s.setpoint_at(5.0), 1000.0);
}
#[test]
fn up_down_returns_to_zero() {
let s = Scenario::ramp_and_hold(600.0, 0.1, 0.2).with_profile(SetpointProfile::UpDown);
assert!((s.total_s() - 0.6).abs() < 1e-6);
assert!((s.setpoint_at(0.05) - 300.0).abs() < 1e-3);
assert_close(s.setpoint_at(0.2), 600.0);
assert!((s.setpoint_at(0.35) - 300.0).abs() < 1e-3);
assert_close(s.setpoint_at(0.59), 0.0);
}
#[test]
fn degenerate_scenario_reports_one_sample() {
let s = Scenario::step(1.0, 0.0);
assert_eq!(s.samples(), 1);
let s = Scenario::step(1.0, 1.0).with_dt_s(0.0);
assert_eq!(s.samples(), 1);
}
}
-230
View File
@@ -1,230 +0,0 @@
//! 试验轨迹:一次场景重放的全部采样。
/// 一条完整试验轨迹(各列等长,索引即采样序号)。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Trace {
/// 采样时刻(秒)。
pub t_s: Vec<f32>,
/// 该时刻的设定值(rpm)。
pub setpoint_rpm: Vec<f32>,
/// 该时刻的实测转速(rpm,物理方向)。
pub rpm: Vec<f32>,
/// 该时刻下发的 effort(raw,已限幅)。
pub effort_raw: Vec<i16>,
/// 该时刻控制器输出是否贴着限幅(饱和)。
pub saturated: Vec<bool>,
}
impl Trace {
/// 预留容量的空轨迹。
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
Self {
t_s: Vec::with_capacity(capacity),
setpoint_rpm: Vec::with_capacity(capacity),
rpm: Vec::with_capacity(capacity),
effort_raw: Vec::with_capacity(capacity),
saturated: Vec::with_capacity(capacity),
}
}
/// 追加一个采样。
pub fn push(
&mut self,
t_s: f32,
setpoint_rpm: f32,
rpm: f32,
effort_raw: i16,
saturated: bool,
) {
self.t_s.push(t_s);
self.setpoint_rpm.push(setpoint_rpm);
self.rpm.push(rpm);
self.effort_raw.push(effort_raw);
self.saturated.push(saturated);
}
/// 采样数。
#[must_use]
pub fn len(&self) -> usize {
self.rpm.len()
}
/// 是否为空。
#[must_use]
pub fn is_empty(&self) -> bool {
self.rpm.is_empty()
}
/// 最后一段采样(稳态窗口用;越界时返回空切片)。
#[must_use]
pub fn window(&self, from_s: f32, to_s: f32) -> Window<'_> {
let start = self.t_s.partition_point(|&t| t < from_s);
let end = self.t_s.partition_point(|&t| t <= to_s);
let end = end.max(start);
Window { trace: self, range: start..end }
}
/// 全轨迹最大转速(rpm)。
#[must_use]
pub fn peak_rpm(&self) -> f32 {
self.rpm.iter().copied().fold(0.0f32, |acc, v| acc.max(v.abs()))
}
/// 全轨迹最小 / 最大转速(rpm)。
#[must_use]
pub fn rpm_range(&self) -> (f32, f32) {
let min = self.rpm.iter().copied().fold(f32::INFINITY, f32::min);
let max = self.rpm.iter().copied().fold(f32::NEG_INFINITY, f32::max);
if self.rpm.is_empty() { (0.0, 0.0) } else { (min, max) }
}
/// 饱和采样占比(0..=1)。
#[must_use]
pub fn saturated_fraction(&self) -> f32 {
if self.saturated.is_empty() {
return 0.0;
}
let count = self.saturated.iter().filter(|&&s| s).count();
count as f32 / self.saturated.len() as f32
}
/// effort 符号翻转次数(振荡 / 抖振指标)。
#[must_use]
pub fn effort_reversals(&self) -> usize {
let mut reversals = 0usize;
let mut last_sign = 0i16;
for &raw in &self.effort_raw {
let sign = raw.signum();
if sign != 0 {
if last_sign != 0 && sign != last_sign {
reversals += 1;
}
last_sign = sign;
}
}
reversals
}
/// 全部列长度一致(不变量自检)。
#[must_use]
pub fn is_consistent(&self) -> bool {
self.t_s.len() == self.rpm.len()
&& self.setpoint_rpm.len() == self.rpm.len()
&& self.effort_raw.len() == self.rpm.len()
&& self.saturated.len() == self.rpm.len()
}
}
/// 轨迹上的一个时间窗口视图。
#[derive(Debug, Clone)]
pub struct Window<'a> {
trace: &'a Trace,
range: std::ops::Range<usize>,
}
impl Window<'_> {
/// 窗口内采样数。
#[must_use]
pub fn len(&self) -> usize {
self.range.len()
}
/// 窗口是否为空。
#[must_use]
pub fn is_empty(&self) -> bool {
self.range.is_empty()
}
/// 窗口内的实测转速。
#[must_use]
pub fn rpm(&self) -> &[f32] {
&self.trace.rpm[self.range.clone()]
}
/// 窗口内的设定值。
#[must_use]
pub fn setpoint_rpm(&self) -> &[f32] {
&self.trace.setpoint_rpm[self.range.clone()]
}
/// 窗口内的 effort。
#[must_use]
pub fn effort_raw(&self) -> &[i16] {
&self.trace.effort_raw[self.range.clone()]
}
/// 窗口内的时刻。
#[must_use]
pub fn t_s(&self) -> &[f32] {
&self.trace.t_s[self.range.clone()]
}
/// 窗口内转速均值。
#[must_use]
pub fn mean_rpm(&self) -> f32 {
let values = self.rpm();
if values.is_empty() {
return 0.0;
}
values.iter().sum::<f32>() / values.len() as f32
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_trace() -> Trace {
let mut trace = Trace::with_capacity(8);
for i in 0..8 {
let t = i as f32 * 0.1;
trace.push(t, 100.0, t * 100.0, if i % 2 == 0 { 10 } else { -10 }, i > 5);
}
trace
}
#[test]
fn push_keeps_columns_aligned() {
let trace = sample_trace();
assert_eq!(trace.len(), 8);
assert!(trace.is_consistent());
}
#[test]
fn window_selects_half_open_range() {
let trace = sample_trace();
let window = trace.window(0.2, 0.4);
assert_eq!(window.len(), 3); // t = 0.2, 0.3, 0.4
assert_eq!(window.t_s(), &[0.2, 0.3, 0.4]);
assert!((window.mean_rpm() - 30.0).abs() < 1e-3);
}
#[test]
fn window_out_of_range_is_empty() {
let trace = sample_trace();
let window = trace.window(5.0, 6.0);
assert!(window.is_empty());
assert!(window.mean_rpm().abs() < f32::EPSILON);
}
#[test]
fn peak_and_range_are_absolute() {
let trace = sample_trace();
assert!((trace.peak_rpm() - 70.0).abs() < 1e-3);
assert_eq!(trace.rpm_range(), (0.0, 70.0));
}
#[test]
fn saturated_fraction_counts_flags() {
let trace = sample_trace();
assert!((trace.saturated_fraction() - 0.25).abs() < 1e-6);
}
#[test]
fn effort_reversals_counts_sign_changes() {
let trace = sample_trace();
// +,−,+,−,+,−,+,−:每次翻转都计数(首个样本建立方向)。
assert_eq!(trace.effort_reversals(), 7);
}
}
-18
View File
@@ -1,18 +0,0 @@
[package]
name = "dji-esc-slcan"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "SLCAN serial backend (e.g. /dev/ttyACM0) bridging to can-link channels"
[lints]
workspace = true
[dependencies]
can-link = { workspace = true }
slcan = "0.1.3"
serial = "0.4"
tokio = { workspace = true }
tracing = { workspace = true }
-25
View File
@@ -1,25 +0,0 @@
[package]
name = "dji-esc-socketcan"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "SocketCAN backend bridging real Linux CAN interfaces to can-link channels"
[lints]
workspace = true
[dependencies]
can-link = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
socketcan = { workspace = true }
[target.'cfg(target_os = "linux")'.dev-dependencies]
can-link = { workspace = true }
dji-esc = { workspace = true }
motor-core = { workspace = true }
socketcan = { workspace = true }
tokio = { workspace = true }
-96
View File
@@ -1,96 +0,0 @@
//! 真实总线桥接实现(Linux)。
use std::io;
use std::sync::Arc;
use can_link::{CanFrame as LinkFrame, CanLink};
use socketcan::tokio::CanSocket;
use socketcan::{
CanDataFrame, CanFilter, CanFrame as ScFrame, EmbeddedFrame as _, Frame as _,
SocketOptions as _,
};
use tokio::sync::mpsc;
use tracing::{debug, error};
/// 单个链路的通道缓冲(1kHz × 8 电调反馈余量)。
const CHANNEL_BOUND: usize = 512;
/// 打开 CAN 接口并桥接为 [`CanLink`],返回面向总线驱动的端点。
///
/// 内部:
/// - 硬件过滤只放行电调反馈 ID 段(`0x200..=0x20F`,覆盖 0x201–0x208,
/// 非反馈帧再由协议层解码拒绝,双保险);
/// - 两个桥接任务:socket → 通道(反馈)、通道 → socket(命令)。
///
/// # Errors
///
/// 接口不存在 / 权限不足等 `io::Error`。
pub fn open_link(interface: &str) -> io::Result<CanLink> {
let socket = CanSocket::open(interface)?;
socket.set_filters(&[CanFilter::new(0x200, 0x7F0)])?;
let socket = Arc::new(socket);
let (bus_side, bridge_side) = CanLink::pair(CHANNEL_BOUND);
let CanLink { outgoing_tx, incoming_rx } = bridge_side;
tokio::spawn(rx_bridge(Arc::clone(&socket), outgoing_tx));
tokio::spawn(tx_bridge(socket, incoming_rx));
Ok(bus_side)
}
/// socket → 通道:真实总线反馈进入协议 bus。
async fn rx_bridge(socket: Arc<CanSocket>, tx: mpsc::Sender<LinkFrame>) {
loop {
match socket.read_frame().await {
Ok(frame) => {
if let Some(frame) = to_link_frame(&frame) {
if tx.send(frame).await.is_err() {
debug!("bus side closed, rx bridge exiting");
return;
}
}
}
Err(e) => {
// 接口脱落(如 USB 转 CAN 拔出)等致命错误:退出守护进程,
// 由 systemd 拉起重连。
error!(error = %e, "CAN read failed, rx bridge exiting");
return;
}
}
}
}
/// 通道 → socket:协议 bus 命令写入真实总线。
async fn tx_bridge(socket: Arc<CanSocket>, mut rx: mpsc::Receiver<LinkFrame>) {
while let Some(frame) = rx.recv().await {
match to_socketcan_frame(&frame) {
Some(sc_frame) => {
if let Err(e) = socket.write_frame(sc_frame).await {
error!(error = %e, "CAN write failed, tx bridge exiting");
return;
}
}
None => {
debug!(id = frame.id, "drop frame with non-standard CAN id");
}
}
}
debug!("bus side closed, tx bridge exiting");
}
/// socketcan 帧 → 链路帧(仅 8 字节数据帧)。
fn to_link_frame(frame: &ScFrame) -> Option<LinkFrame> {
match frame {
ScFrame::Data(df) if df.data().len() == 8 => {
Some(LinkFrame::new(df.raw_id(), df.data().try_into().ok()?))
}
_ => None,
}
}
/// 链路帧 → socketcan 帧(ID 必须落在 11 位标准 ID 范围内)。
fn to_socketcan_frame(frame: &LinkFrame) -> Option<ScFrame> {
let id = socketcan::StandardId::new(frame.id as u16)?;
let df = CanDataFrame::new(id, &frame.data)?;
Some(ScFrame::Data(df))
}
-11
View File
@@ -1,11 +0,0 @@
//! SocketCAN 桥接:把真实 Linux CAN 接口接入 [`can_link::CanLink`] 通道对。
//!
//! 本 crate 主体仅在 Linux 下编译(依赖 socketcan)。其他平台编译为空壳,
//! 使整个 workspace 可以在任意开发机上构建与测试;实车部署目标板均为
//! Linux。
#[cfg(target_os = "linux")]
mod bridge;
#[cfg(target_os = "linux")]
pub use bridge::open_link;
-249
View File
@@ -1,249 +0,0 @@
//! DJI 电调(C620 / C610)CAN 协议编解码。
//!
//! 帧格式(大端):
//!
//! - 控制(`0x200` 电调 1–4 / `0x1FF` 电调 5–8):
//! `[I1_H, I1_L, I2_H, I2_L, I3_H, I3_L, I4_H, I4_L]`,i16 电流命令,
//! 槽位按组内电调顺序。
//! - 反馈(`0x200 + 电调ID`):
//! `u16 角度(0..8191) + i16 转速rpm + i16 转矩电流 + i8 温度℃ + 保留`。
//!
//! 本模块只做纯编解码;主机侧速度/位置环由 [`motor_core`] 算法在
//! [`crate::bus`] 中按节拍调用。
use crate::frame::{
COMMAND_FRAME_ID_PRIMARY, COMMAND_FRAME_ID_SECONDARY, CanFrame, FEEDBACK_FRAME_ID_BASE,
};
use thiserror::Error;
/// 电调 ID 下限。
pub const MIN_ESC_ID: u8 = 1;
/// 电调 ID 上限。
pub const MAX_ESC_ID: u8 = 8;
/// 电调型号决定电流标度。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EscModel {
/// C620(配 GM3508):±16384 ↔ ±20 A。
C620,
/// C610(配 M2006):±10000 ↔ ±10 A。
///
/// 接入前需实测核对。
C610,
}
impl EscModel {
/// 满量程电流命令(raw)。
#[must_use]
pub const fn max_current_raw(self) -> i16 {
match self {
Self::C620 => 16384,
Self::C610 => 10_000,
}
}
/// 满量程电流(安培)。
#[must_use]
pub const fn max_current_amp(self) -> f32 {
match self {
Self::C620 => 20.0,
Self::C610 => 10.0,
}
}
/// 安培 → raw(限幅)。
#[must_use]
pub fn raw_from_amp(self, amp: f32) -> i16 {
let raw = amp * f32::from(self.max_current_raw()) / self.max_current_amp();
(raw.clamp(-f32::from(self.max_current_raw()), f32::from(self.max_current_raw())) as i16)
.clamp(-self.max_current_raw(), self.max_current_raw())
}
/// raw → 安培。
#[must_use]
pub fn amp_from_raw(self, raw: i16) -> f32 {
f32::from(raw) * self.max_current_amp() / f32::from(self.max_current_raw())
}
}
/// 一帧电调反馈(原始、电调侧,未经方向修正)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EscFeedback {
/// 转子机械角原始值(0..8191,对应 0..360°)。
pub angle_raw: u16,
/// 转子转速(rpm,电调侧符号)。
pub speed_rpm: i16,
/// 转矩电流 raw(电调侧符号)。
pub current_raw: i16,
/// 电机温度(℃)。
pub temperature_c: i8,
}
/// 帧解码错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum DecodeError {
/// 帧不属于电调反馈范围(0x201..=0x208)。
#[error("frame id 0x{id:03X} is not an ESC feedback frame (0x201..=0x208)")]
NotFeedback {
/// 收到的帧 ID。
id: u32,
},
}
/// 电调 ID → 其电流命令所在的组控制帧 ID。
///
/// 返回 `None` 表示 ID 越界。
#[must_use]
pub const fn command_frame_id(esc_id: u8) -> Option<u32> {
match esc_id {
1..=4 => Some(COMMAND_FRAME_ID_PRIMARY),
5..=8 => Some(COMMAND_FRAME_ID_SECONDARY),
_ => None,
}
}
/// 将 raw 电流限幅到电调满量程。
#[must_use]
pub const fn clamp_current(model: EscModel, raw: i16) -> i16 {
if raw > model.max_current_raw() {
model.max_current_raw()
} else if raw < -model.max_current_raw() {
-model.max_current_raw()
} else {
raw
}
}
/// 组装一个控制组帧(`0x200` 或 `0x1FF`)。
///
/// `currents` 按组内槽位排列:主组对应电调 1–4,副组对应电调 5–8。
/// 本函数只做纯编码;电流限幅由驱动层按各电机型号与限流配置完成
/// (见 [`clamp_current`])。
#[must_use]
pub fn encode_current_frame(frame_id: u32, currents: [i16; 4]) -> CanFrame {
let mut data = [0u8; 8];
for (slot, raw) in currents.iter().enumerate() {
data[slot * 2] = (raw >> 8) as u8;
data[slot * 2 + 1] = (raw & 0xFF) as u8;
}
CanFrame::new(frame_id, data)
}
/// 组装一帧电调反馈(仿真器 / 测试用)。
#[must_use]
pub fn encode_feedback_frame(esc_id: u8, feedback: &EscFeedback) -> CanFrame {
let mut data = [0u8; 8];
data[0..2].copy_from_slice(&feedback.angle_raw.to_be_bytes());
data[2..4].copy_from_slice(&feedback.speed_rpm.to_be_bytes());
data[4..6].copy_from_slice(&feedback.current_raw.to_be_bytes());
data[6] = feedback.temperature_c as u8;
CanFrame::new(FEEDBACK_FRAME_ID_BASE + u32::from(esc_id), data)
}
/// 解码一帧电调反馈,返回 `(电调ID, 反馈)`。
///
/// # Errors
///
/// 帧不属于反馈 ID 范围(0x201..=0x208)时返回 [`DecodeError::NotFeedback`]。
pub fn decode_feedback(frame: &CanFrame) -> Result<(u8, EscFeedback), DecodeError> {
if frame.id <= FEEDBACK_FRAME_ID_BASE
|| frame.id > FEEDBACK_FRAME_ID_BASE + u32::from(MAX_ESC_ID)
{
return Err(DecodeError::NotFeedback { id: frame.id });
}
let esc_id = (frame.id - FEEDBACK_FRAME_ID_BASE) as u8;
Ok((
esc_id,
EscFeedback {
angle_raw: u16::from_be_bytes([frame.data[0], frame.data[1]]),
speed_rpm: i16::from_be_bytes([frame.data[2], frame.data[3]]),
current_raw: i16::from_be_bytes([frame.data[4], frame.data[5]]),
temperature_c: frame.data[6] as i8,
},
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_frame_id_routing() {
assert_eq!(command_frame_id(1), Some(0x200));
assert_eq!(command_frame_id(4), Some(0x200));
assert_eq!(command_frame_id(5), Some(0x1FF));
assert_eq!(command_frame_id(8), Some(0x1FF));
assert_eq!(command_frame_id(0), None);
assert_eq!(command_frame_id(9), None);
}
#[test]
fn encode_current_frame_bytes() {
let frame = encode_current_frame(0x200, [16384, -16384, 0, 258]);
assert_eq!(frame.id, 0x200);
assert_eq!(frame.data, [0x40, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x01, 0x02]);
let frame = encode_current_frame(0x1FF, [10000, -10000, 0, 0]);
assert_eq!(frame.id, 0x1FF);
assert_eq!(frame.data, [0x27, 0x10, 0xD8, 0xF0, 0x00, 0x00, 0x00, 0x00]);
}
#[test]
fn decode_feedback_known_bytes() {
let frame = CanFrame::new(0x205, [0x1F, 0xFF, 0xFE, 0x94, 0x00, 0x7B, 45, 0]);
let (esc_id, fb) = decode_feedback(&frame).unwrap();
assert_eq!(esc_id, 5);
assert_eq!(fb.angle_raw, 8191);
assert_eq!(fb.speed_rpm, -364);
assert_eq!(fb.current_raw, 123);
assert_eq!(fb.temperature_c, 45);
}
#[test]
fn decode_feedback_rejects_non_feedback_ids() {
for id in [0x000, 0x1FF, 0x200, 0x209, 0x7FF] {
let frame = CanFrame::new(id, [0; 8]);
assert_eq!(
decode_feedback(&frame),
Err(DecodeError::NotFeedback { id }),
"id = 0x{id:03X}"
);
}
}
#[test]
fn feedback_roundtrip_all_ids() {
let fb = EscFeedback {
angle_raw: 8191,
speed_rpm: -9254,
current_raw: -16384,
temperature_c: -40,
};
for esc_id in MIN_ESC_ID..=MAX_ESC_ID {
let frame = encode_feedback_frame(esc_id, &fb);
assert_eq!(frame.id, 0x200 + u32::from(esc_id));
assert_eq!(decode_feedback(&frame).unwrap(), (esc_id, fb));
}
}
#[test]
fn amp_conversion() {
let c620 = EscModel::C620;
assert!((c620.amp_from_raw(16384) - 20.0).abs() < 1e-4);
assert!((c620.amp_from_raw(-8192) + 10.0).abs() < 1e-4);
assert_eq!(c620.raw_from_amp(20.0), 16384);
assert_eq!(c620.raw_from_amp(-5.0), -4096);
// 超量程限幅。
assert_eq!(c620.raw_from_amp(99.0), 16384);
let c610 = EscModel::C610;
assert!((c610.amp_from_raw(10000) - 10.0).abs() < 1e-4);
assert_eq!(c610.raw_from_amp(10.0), 10000);
}
#[test]
fn clamp_current_respects_model() {
assert_eq!(clamp_current(EscModel::C620, 20000), 16384);
assert_eq!(clamp_current(EscModel::C610, 15000), 10000);
assert_eq!(clamp_current(EscModel::C620, 1000), 1000);
}
}
-13
View File
@@ -1,13 +0,0 @@
//! DJI 电调协议帧 ID 常量。
//!
//! 帧与链路类型([`CanFrame`] / [`CanLink`])由 [`can_link`] 提供,
//! 本模块只保留 **C620/C610 电流协议族**的 ID 约定。
pub use can_link::{CanFrame, CanLink};
/// 控制帧 ID:电调 1–4 的电流命令(8 字节 = 4 × i16 大端)。
pub const COMMAND_FRAME_ID_PRIMARY: u32 = 0x200;
/// 控制帧 ID:电调 5–8 的电流命令。
pub const COMMAND_FRAME_ID_SECONDARY: u32 = 0x1FF;
/// 反馈帧 ID 基址:`0x200 + 电调ID`(0x201–0x208)。
pub const FEEDBACK_FRAME_ID_BASE: u32 = 0x200;
-29
View File
@@ -1,29 +0,0 @@
//! DJI RoboMaster 电调(C620/C610)CAN 协议与异步驱动。
//!
//! 分层:
//!
//! - [`codec`]:纯函数帧编解码(C620 手册协议);
//! - [`turn`]:单圈编码器 → 连续多圈角;
//! - [`frame`]:协议帧 ID 常量(帧/链路类型见 [`can_link`]);
//! - [`bus`]:[`EscBus`] 驱动——反馈分发、主机速度/位置环、分组组帧、离线安全。
//!
//! 电机以 [`motor_core`] 能力 trait 呈现,物理量均已按 `EscConfig::direction`
//! 完成方向修正。
//!
//! 被控对象仿真(假电调)**不在本 crate**:见 `dji-esc-sim`。本 crate 只描述
//! 真实电调在总线上的行为,不假设对面是谁。
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub mod bus;
pub mod codec;
pub mod frame;
pub mod turn;
pub use bus::{BusOptions, EscBus, EscBusError, EscBusHandle, EscConfig, EscMotor};
pub use can_link::{CanFrame, CanLink};
pub use codec::{
DecodeError, EscFeedback, EscModel, MAX_ESC_ID, MIN_ESC_ID, clamp_current, command_frame_id,
decode_feedback, encode_current_frame, encode_feedback_frame,
};
pub use frame::{COMMAND_FRAME_ID_PRIMARY, COMMAND_FRAME_ID_SECONDARY, FEEDBACK_FRAME_ID_BASE};
pub use turn::{ENCODER_TICKS, TurnTracker};
-134
View File
@@ -1,134 +0,0 @@
//! 多圈累计:单圈编码器原始角 → 连续圈数。
/// C620/C610 反馈编码器单圈计数(0..8191 对应 0..360°)。
pub const ENCODER_TICKS: u16 = 8192;
/// 半圈阈值:帧间角变化超过它视为发生回绕。
const HALF_TURN_TICKS: i32 = 4096;
/// 单圈机械角跟踪器:把 0..8191 的原始角序列转成连续累计角(单位:圈)。
///
/// 假设相邻两帧间转子转动不超过半圈。1kHz 反馈下 GM3508 转子上限约
/// 9300 rpm ≈ 0.155 圈/ms,余量充足。
#[derive(Debug, Clone, Default)]
pub struct TurnTracker {
revolutions: i32,
prev_raw: Option<u16>,
last_angle_rev: f32,
}
impl TurnTracker {
/// 创建跟踪器。
#[must_use]
pub const fn new() -> Self {
Self { revolutions: 0, prev_raw: None, last_angle_rev: 0.0 }
}
/// 送入新的原始角,返回连续累计角(圈)。
pub fn update(&mut self, raw: u16) -> f32 {
if let Some(prev) = self.prev_raw {
let delta = i32::from(raw) - i32::from(prev);
if delta > HALF_TURN_TICKS {
// 正向跨越 8191 → 0。
self.revolutions -= 1;
} else if delta < -HALF_TURN_TICKS {
// 反向跨越 0 → 8191。
self.revolutions += 1;
}
}
self.prev_raw = Some(raw);
self.last_angle_rev = self.revolutions as f32 + f32::from(raw) / 8192.0;
self.last_angle_rev
}
/// 最近一次 [`TurnTracker::update`] 的返回值。
#[must_use]
pub const fn last_angle_rev(&self) -> f32 {
self.last_angle_rev
}
/// 清零回到初始状态。
pub fn reset(&mut self) {
*self = Self::new();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_reading_is_raw_fraction() {
let mut t = TurnTracker::new();
assert!((t.update(4096) - 0.5).abs() < 1e-4);
}
#[test]
fn forward_wrap_counts_up() {
let mut t = TurnTracker::new();
t.update(8000);
let after = t.update(100); // 8000 → 8191 → 0 → 100
assert!((after - (8192.0 + 100.0) / 8192.0).abs() < 1e-4, "{after}");
}
#[test]
fn reverse_wrap_counts_down() {
let mut t = TurnTracker::new();
t.update(100);
let after = t.update(8000); // 100 → 0 → 8191 → 8000(反向)
assert!((after - (8000.0 - 8192.0) / 8192.0).abs() < 1e-4, "{after}");
}
#[test]
fn small_deltas_do_not_wrap() {
let mut t = TurnTracker::new();
t.update(0);
assert!((t.update(4000) - 4000.0 / 8192.0).abs() < 1e-4);
assert!((t.update(4100) - 4100.0 / 8192.0).abs() < 1e-4);
}
#[test]
fn multi_turn_forward_then_reverse_returns() {
let mut t = TurnTracker::new();
// 正向 3 整圈:每圈 0 → 4000 → 8000 → 100(跨 8191 回绕,净 +1)。
for _ in 0..3 {
t.update(0);
t.update(4000);
t.update(8000);
t.update(100);
}
let fwd = t.last_angle_rev();
let expected_fwd = 3.0 + 100.0 / 8192.0;
assert!((fwd - expected_fwd).abs() < 1e-4, "{fwd} vs {expected_fwd}");
// 沿同一路径反向退回 3 圈:100 → 8000(反向回绕)→ 4000 → 0,净 -1/圈。
for _ in 0..3 {
t.update(8000);
t.update(4000);
t.update(0);
}
let back = t.last_angle_rev();
assert!(back.abs() < 1e-4, "should return to 0, got {back}");
}
#[test]
fn jitter_at_boundary_stays_put() {
// 0 附近抖动不产生虚假圈数。
let mut t = TurnTracker::new();
t.update(8191);
assert!((t.update(0) - 1.0).abs() < 1e-4); // 真实跨越一次
assert!((t.update(2) - (2.0 / 8192.0 + 1.0)).abs() < 1e-4);
assert!((t.update(8190) - (8190.0 / 8192.0)).abs() < 1e-4); // 抖回
assert!((t.update(5) - (1.0 + 5.0 / 8192.0)).abs() < 1e-4);
}
#[test]
fn reset_forgets_history() {
let mut t = TurnTracker::new();
t.update(8000);
t.update(100);
t.reset();
assert_eq!(t.last_angle_rev().to_bits(), 0.0f32.to_bits());
assert!((t.update(4096) - 0.5).abs() < 1e-4);
}
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "esc-bus"
description = "Asynchronous ESC frame pump, state, leases, and safety"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
can = { workspace = true }
motor = { workspace = true }
motor-protocol = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
sim-harness = { workspace = true }
motor-control = { workspace = true }
motor-model = { workspace = true }
experiment = { workspace = true }
tokio = { workspace = true, features = ["test-util"] }
approx = { workspace = true }
@@ -1,3 +1,5 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! `EscBus` 异步驱动:协议状态机与 1kHz 控制节拍。 //! `EscBus` 异步驱动:协议状态机与 1kHz 控制节拍。
//! //!
//! [`EscBus::spawn`] 消费一个 [`CanLink`],启动收发两个任务: //! [`EscBus::spawn`] 消费一个 [`CanLink`],启动收发两个任务:
@@ -12,21 +14,21 @@
use std::sync::{Arc, Mutex, MutexGuard}; use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration; use std::time::Duration;
use motor_core::{ use can::{CanFrame, CanLink};
Command, CommandMode, Disable, EffortDrive, MotorFeedback, MotorState, Pid, PositionController, use motor_protocol::common::{
PositionDrive, VelocityController, VelocityDrive, VelocityGains, EscFeedback, EscModel, clamp_current, command_frame_id, decode_feedback, encode_current_frame,
}; };
use motor_protocol::{COMMAND_FRAME_ID_PRIMARY, COMMAND_FRAME_ID_SECONDARY, TurnTracker};
use thiserror::Error; use thiserror::Error;
use tokio::sync::{mpsc, watch}; use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio::time::{Instant, Interval, MissedTickBehavior}; use tokio::time::{Instant, Interval, MissedTickBehavior};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use crate::codec::{ use motor::{
EscFeedback, EscModel, clamp_current, command_frame_id, decode_feedback, encode_current_frame, Command, Disable, EffortDrive, EffortPolicy, MotorFeedback, MotorState, PolicyInput,
PolicySample, PositionDrive, VelocityDrive,
}; };
use crate::frame::{COMMAND_FRAME_ID_PRIMARY, COMMAND_FRAME_ID_SECONDARY, CanFrame, CanLink};
use crate::turn::TurnTracker;
/// 锁辅助:中毒锁直接恢复内部数据(锁中毒只意味着持锁线程恐慌, /// 锁辅助:中毒锁直接恢复内部数据(锁中毒只意味着持锁线程恐慌,
/// 状态本身仍一致,驱动机不应因此停摆)。 /// 状态本身仍一致,驱动机不应因此停摆)。
@@ -45,7 +47,7 @@ pub struct EscConfig {
pub model: EscModel, pub model: EscModel,
/// 减速比(电机转子 : 输出轴)。 /// 减速比(电机转子 : 输出轴)。
/// ///
/// 驱动层**不使用**此字段;由上层 [`motor_core::OutputShaft`] 应用。 /// 驱动层**不使用**此字段;由上层 `crate::OutputShaft` 应用。
/// 保留于此作为配置元数据,`chassisd` 包装轮子时必须读取本字段(单一来源)。 /// 保留于此作为配置元数据,`chassisd` 包装轮子时必须读取本字段(单一来源)。
pub gear_ratio: f32, pub gear_ratio: f32,
/// 安装方向:`+1.0` 或 `-1.0`。反装电机的读写符号在此统一翻转, /// 安装方向:`+1.0` 或 `-1.0`。反装电机的读写符号在此统一翻转,
@@ -53,14 +55,6 @@ pub struct EscConfig {
pub direction: f32, pub direction: f32,
/// effort 限幅(raw,`1..=型号满量程`)。 /// effort 限幅(raw,`1..=型号满量程`)。
pub current_limit: i16, pub current_limit: i16,
/// 速度环比例增益(raw / rpm)。
pub kp: f32,
/// 速度环积分增益(raw / (rpm·s))。
pub ki: f32,
/// 速度环微分增益。
pub kd: f32,
/// 速度前馈(raw / rpm)。
pub feedforward: f32,
} }
impl EscConfig { impl EscConfig {
@@ -75,10 +69,6 @@ impl EscConfig {
gear_ratio: 3591.0 / 187.0, gear_ratio: 3591.0 / 187.0,
direction, direction,
current_limit: 12_000, current_limit: 12_000,
kp: 10.0,
ki: 0.5,
kd: 0.0,
feedforward: 1.5,
} }
} }
@@ -95,19 +85,9 @@ impl EscConfig {
gear_ratio: 36.0, gear_ratio: 36.0,
direction, direction,
current_limit: 8_000, current_limit: 8_000,
kp: 8.0,
ki: 0.4,
kd: 0.0,
feedforward: 1.0,
} }
} }
/// 速度环增益组(可整定参数)。
#[must_use]
pub fn velocity_gains(&self) -> VelocityGains {
VelocityGains::new(self.kp, self.ki, self.kd, self.feedforward)
}
fn validate(&self) -> Result<(), String> { fn validate(&self) -> Result<(), String> {
if command_frame_id(self.esc_id).is_none() { if command_frame_id(self.esc_id).is_none() {
return Err(format!("esc_id {} out of range 1..=8", self.esc_id)); return Err(format!("esc_id {} out of range 1..=8", self.esc_id));
@@ -187,9 +167,6 @@ struct MotorShared {
cmd: Mutex<Command>, cmd: Mutex<Command>,
/// 命令写入时刻(看门狗用)。 /// 命令写入时刻(看门狗用)。
cmd_at: Mutex<Option<Instant>>, cmd_at: Mutex<Option<Instant>>,
last_mode: Mutex<CommandMode>,
vel_ctrl: Mutex<VelocityController>,
pos_ctrl: Mutex<PositionController>,
turn: Mutex<TurnTracker>, turn: Mutex<TurnTracker>,
last_feedback: Mutex<Option<Instant>>, last_feedback: Mutex<Option<Instant>>,
offline_warned: std::sync::atomic::AtomicBool, offline_warned: std::sync::atomic::AtomicBool,
@@ -197,17 +174,11 @@ struct MotorShared {
impl MotorShared { impl MotorShared {
fn new(cfg: EscConfig) -> Self { fn new(cfg: EscConfig) -> Self {
let limit = cfg.current_limit;
let pid = Pid::new(cfg.kp, cfg.ki, cfg.kd, -f32::from(limit), f32::from(limit));
let pos_pid = Pid::new(cfg.kp, cfg.ki, cfg.kd, -f32::from(limit), f32::from(limit));
Self { Self {
vel_ctrl: Mutex::new(VelocityController::new(pid, cfg.feedforward)),
pos_ctrl: Mutex::new(PositionController::direct(pos_pid)),
cfg, cfg,
state: Mutex::new(MotorState::default()), state: Mutex::new(MotorState::default()),
cmd: Mutex::new(Command::Disabled), cmd: Mutex::new(Command::Disabled),
cmd_at: Mutex::new(None), cmd_at: Mutex::new(None),
last_mode: Mutex::new(CommandMode::Disabled),
turn: Mutex::new(TurnTracker::new()), turn: Mutex::new(TurnTracker::new()),
last_feedback: Mutex::new(None), last_feedback: Mutex::new(None),
offline_warned: std::sync::atomic::AtomicBool::new(false), offline_warned: std::sync::atomic::AtomicBool::new(false),
@@ -219,21 +190,6 @@ impl MotorShared {
*lock(&self.cmd_at) = Some(Instant::now()); *lock(&self.cmd_at) = Some(Instant::now());
} }
/// 当前速度环增益(来自控制器,而非启动时的 `cfg`)。
fn velocity_gains(&self) -> VelocityGains {
lock(&self.vel_ctrl).gains()
}
/// 热更新速度环增益:重建控制器(等价于 reset,避免积分冲击)。
///
/// 只替换增益,不动限幅 / 型号 / 方向 —— 那些是安全与机械约束,
/// 不属于可在线试错的整定量。
fn set_velocity_gains(&self, gains: VelocityGains) {
let limit = self.cfg.current_limit;
let mut controller = lock(&self.vel_ctrl);
*controller = VelocityController::from_gains(limit, gains);
}
/// 应用一帧反馈(原始电调侧值 → 方向修正后的物理量)。 /// 应用一帧反馈(原始电调侧值 → 方向修正后的物理量)。
fn apply_feedback(&self, fb: EscFeedback, now: Instant) { fn apply_feedback(&self, fb: EscFeedback, now: Instant) {
let esc_rev = lock(&self.turn).update(fb.angle_raw); let esc_rev = lock(&self.turn).update(fb.angle_raw);
@@ -251,68 +207,26 @@ impl MotorShared {
self.offline_warned.store(false, std::sync::atomic::Ordering::Relaxed); self.offline_warned.store(false, std::sync::atomic::Ordering::Relaxed);
} }
/// 复位主机侧控制器(离线或模式切换时)。 /// Produce one policy sample while enforcing feedback and command leases.
fn reset_controllers(&self) { fn sample(
lock(&self.vel_ctrl).reset();
lock(&self.pos_ctrl).reset();
}
/// 求值本节拍应下发的 effort 命令(电调侧 raw,含方向翻转与限幅)。
fn compute_effort(
&self, &self,
dt: f32,
feedback_timeout: Duration, feedback_timeout: Duration,
command_timeout: Duration, command_timeout: Duration,
now: Instant, now: Instant,
) -> i16 { ) -> PolicySample {
let online = matches!(*lock(&self.last_feedback), Some(t) if now.duration_since(t) <= feedback_timeout); let online = matches!(
if !online { *lock(&self.last_feedback),
if !self.offline_warned.swap(true, std::sync::atomic::Ordering::Relaxed) { Some(t) if now.saturating_duration_since(t) <= feedback_timeout
warn!(motor = %self.cfg.name, esc_id = self.cfg.esc_id, "feedback offline, forcing zero current"); );
} if !online && !self.offline_warned.swap(true, std::sync::atomic::Ordering::Relaxed) {
// 复位控制器,避免重新上线时旧积分冲击。 warn!(motor = %self.cfg.name, esc_id = self.cfg.esc_id, "feedback offline, forcing zero current");
self.reset_controllers();
*lock(&self.last_mode) = CommandMode::Disabled;
return 0;
} }
// 命令看门狗:上层任务死亡/卡死后旧指令不得持续输出。 let fresh = matches!(
let cmd_fresh = *lock(&self.cmd_at),
matches!(*lock(&self.cmd_at), Some(t) if now.duration_since(t) <= command_timeout); Some(t) if now.saturating_duration_since(t) <= command_timeout
if !cmd_fresh { );
self.reset_controllers(); let command = if online && fresh { *lock(&self.cmd) } else { Command::Disabled };
*lock(&self.last_mode) = CommandMode::Disabled; PolicySample { id: self.cfg.esc_id, state: *lock(&self.state), command, online }
return 0;
}
let cmd = *lock(&self.cmd);
// 模式切换复位:避免积分/微分记忆冲击。
{
let mut last_mode = lock(&self.last_mode);
if *last_mode != cmd.mode() {
debug!(motor = %self.cfg.name, from = ?*last_mode, to = ?cmd.mode(), "command mode switch, resetting controllers");
self.reset_controllers();
*last_mode = cmd.mode();
}
}
let dir = self.cfg.direction;
let raw = match cmd {
Command::Disabled => 0,
Command::EffortRaw(raw) => (f32::from(raw) * dir) as i16,
Command::Velocity(target) => {
let measured = lock(&self.state).rotor_speed_rpm;
let out = lock(&self.vel_ctrl).update(target, measured, dt);
(out * dir) as i16
}
Command::Position(target) => {
let (measured_rev, measured_rpm) = {
let st = lock(&self.state);
(st.rotor_revolutions, st.rotor_speed_rpm)
};
let out = lock(&self.pos_ctrl).update(target, measured_rev, measured_rpm, dt);
(out * dir) as i16
}
};
let limit = self.cfg.current_limit;
clamp_current(self.cfg.model, raw.clamp(-limit, limit))
} }
} }
@@ -353,25 +267,6 @@ impl EscMotor {
pub fn last_feedback(&self) -> Option<Instant> { pub fn last_feedback(&self) -> Option<Instant> {
*lock(&self.shared.last_feedback) *lock(&self.shared.last_feedback)
} }
/// 当前速度环增益。
#[must_use]
pub fn velocity_gains(&self) -> VelocityGains {
self.shared.velocity_gains()
}
/// 热更新速度环增益(**不需重启总线**,下一发送节拍生效)。
///
/// 用于自动整定:一次真机试验只改增益,不必反复开关串口 / CAN 接口。
/// 控制器被重建,历史积分被丢弃——这正是一次新试验想要的起点。
///
/// # Panics
///
/// 增益含 NaN / inf 时 panic(同 [`Pid::new`]);调用方应先检查
/// [`VelocityGains::is_finite`]。
pub fn set_velocity_gains(&self, gains: VelocityGains) {
self.shared.set_velocity_gains(gains);
}
} }
impl MotorFeedback for EscMotor { impl MotorFeedback for EscMotor {
@@ -426,11 +321,12 @@ impl EscBus {
/// # Errors /// # Errors
/// ///
/// 配置为空、电调 ID 重复或单台配置非法时返回 [`EscBusError`]。 /// 配置为空、电调 ID 重复或单台配置非法时返回 [`EscBusError`]。
pub fn spawn( pub fn spawn<P: EffortPolicy>(
link: CanLink, link: CanLink,
configs: Vec<EscConfig>, configs: Vec<EscConfig>,
options: BusOptions, options: BusOptions,
) -> Result<EscBusHandle, EscBusError> { policy: P,
) -> Result<EscBusHandle<P>, EscBusError> {
if configs.is_empty() { if configs.is_empty() {
return Err(EscBusError::NoMotors); return Err(EscBusError::NoMotors);
} }
@@ -448,23 +344,30 @@ impl EscBus {
let (shutdown_tx, shutdown_rx) = watch::channel(false); let (shutdown_tx, shutdown_rx) = watch::channel(false);
let CanLink { outgoing_tx, incoming_rx } = link; let CanLink { outgoing_tx, incoming_rx } = link;
let motors = Arc::new(motors); let motors = Arc::new(motors);
let policy = Arc::new(Mutex::new(policy));
let rx_handle = let rx_handle =
tokio::spawn(rx_loop(incoming_rx, Arc::clone(&motors), shutdown_rx.clone())); tokio::spawn(rx_loop(incoming_rx, Arc::clone(&motors), shutdown_rx.clone()));
let tx_handle = let tx_handle = tokio::spawn(tx_loop(
tokio::spawn(tx_loop(outgoing_tx, Arc::clone(&motors), options, shutdown_rx)); outgoing_tx,
Arc::clone(&motors),
Arc::clone(&policy),
options,
shutdown_rx,
));
Ok(EscBusHandle { motors, shutdown_tx, tasks: vec![rx_handle, tx_handle] }) Ok(EscBusHandle { motors, policy, shutdown_tx, tasks: vec![rx_handle, tx_handle] })
} }
} }
/// `EscBus` 的控制句柄。 /// `EscBus` 的控制句柄。
pub struct EscBusHandle { pub struct EscBusHandle<P> {
motors: Arc<Vec<EscMotor>>, motors: Arc<Vec<EscMotor>>,
policy: Arc<Mutex<P>>,
shutdown_tx: watch::Sender<bool>, shutdown_tx: watch::Sender<bool>,
tasks: Vec<JoinHandle<()>>, tasks: Vec<JoinHandle<()>>,
} }
impl EscBusHandle { impl<P: EffortPolicy> EscBusHandle<P> {
/// 按电调 ID 取电机句柄。 /// 按电调 ID 取电机句柄。
#[must_use] #[must_use]
pub fn motor(&self, esc_id: u8) -> Option<&EscMotor> { pub fn motor(&self, esc_id: u8) -> Option<&EscMotor> {
@@ -477,6 +380,11 @@ impl EscBusHandle {
&self.motors &self.motors
} }
/// Mutate the composed control policy between bus ticks.
pub fn with_policy<R>(&self, update: impl FnOnce(&mut P) -> R) -> R {
update(&mut lock(&self.policy))
}
/// 停止收发任务并等待退出。 /// 停止收发任务并等待退出。
pub async fn shutdown(self) { pub async fn shutdown(self) {
let _ = self.shutdown_tx.send(true); let _ = self.shutdown_tx.send(true);
@@ -512,15 +420,15 @@ async fn rx_loop(
} }
} }
async fn tx_loop( async fn tx_loop<P: EffortPolicy>(
outgoing: mpsc::Sender<CanFrame>, outgoing: mpsc::Sender<CanFrame>,
motors: Arc<Vec<EscMotor>>, motors: Arc<Vec<EscMotor>>,
policy: Arc<Mutex<P>>,
options: BusOptions, options: BusOptions,
mut shutdown: watch::Receiver<bool>, mut shutdown: watch::Receiver<bool>,
) { ) {
let nominal_dt = options.period.as_secs_f32(); let nominal_dt = options.period.as_secs_f32();
let mut ticker = tick_every(options.period); let mut ticker = tick_every(options.period);
// 实测 dt:丢拍/负载抖动时 PID 不得按名义 1ms 硬算(F-1)。
let mut last_tick = Instant::now(); let mut last_tick = Instant::now();
loop { loop {
tokio::select! { tokio::select! {
@@ -530,8 +438,21 @@ async fn tx_loop(
let now = Instant::now(); let now = Instant::now();
let raw_dt = now.saturating_duration_since(last_tick).as_secs_f32(); let raw_dt = now.saturating_duration_since(last_tick).as_secs_f32();
last_tick = now; last_tick = now;
// 夹在 [0.5, 4] × 名义周期,避免首拍/长卡死把积分或微分打飞。
let dt = raw_dt.clamp(nominal_dt * 0.5, nominal_dt * 4.0); let dt = raw_dt.clamp(nominal_dt * 0.5, nominal_dt * 4.0);
let mut samples: [PolicySample; 8] = std::array::from_fn(|index| PolicySample {
id: u8::try_from(index + 1).unwrap_or(0),
state: MotorState::default(),
command: Command::Disabled,
online: false,
});
for motor in motors.iter() {
let sample =
motor.shared.sample(options.feedback_timeout, options.command_timeout, now);
if let Some(slot) = samples.get_mut(usize::from(sample.id.saturating_sub(1))) {
*slot = sample;
}
}
let efforts = lock(&policy).step(&PolicyInput { samples, dt });
for (frame_id, group_start) in for (frame_id, group_start) in
[(COMMAND_FRAME_ID_PRIMARY, 1u8), (COMMAND_FRAME_ID_SECONDARY, 5u8)] [(COMMAND_FRAME_ID_PRIMARY, 1u8), (COMMAND_FRAME_ID_SECONDARY, 5u8)]
{ {
@@ -545,11 +466,12 @@ async fn tx_loop(
let mut currents = [0i16; 4]; let mut currents = [0i16; 4];
for motor in &group { for motor in &group {
let slot = usize::from(motor.id() - group_start); let slot = usize::from(motor.id() - group_start);
currents[slot] = motor.shared.compute_effort( let policy_index = usize::from(motor.id().saturating_sub(1));
dt, let physical = efforts.get(policy_index).copied().unwrap_or(0);
options.feedback_timeout, let directed = (f32::from(physical) * motor.shared.cfg.direction) as i16;
options.command_timeout, currents[slot] = clamp_current(
now, motor.shared.cfg.model,
directed.clamp(-motor.shared.cfg.current_limit, motor.shared.cfg.current_limit),
); );
} }
if outgoing.send(encode_current_frame(frame_id, currents)).await.is_err() { if outgoing.send(encode_current_frame(frame_id, currents)).await.is_err() {
@@ -6,24 +6,35 @@
use std::time::Duration; use std::time::Duration;
use dji_esc::{ use can::{CanFrame, CanLink};
BusOptions, CanFrame, CanLink, EscBus, EscBusError, EscConfig, EscFeedback, use esc_bus::{BusOptions, EscBus, EscBusError, EscConfig};
encode_feedback_frame, use motor::{Disable, EffortDrive, MotorFeedback, VelocityDrive};
}; use motor_control::{MotorPolicy, VelocityGains};
use dji_esc_sim::spawn_plant; use motor_protocol::{EscFeedback, encode_feedback_frame};
use motor_core::{Disable, EffortDrive, MotorFeedback, VelocityDrive, VelocityGains}; use sim_harness::spawn_plant;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio::time::{MissedTickBehavior, interval, sleep, timeout}; use tokio::time::{MissedTickBehavior, interval, sleep, timeout};
/// 与 `PlantParams::gm3508` 增益精确匹配的速度环参数(ff = 16384 / 9254)。 /// 与 `PlantParams::gm3508` 增益精确匹配的速度环参数(ff = 16384 / 9254)。
fn tuned(esc_id: u8, direction: f32) -> EscConfig { fn tuned(esc_id: u8, direction: f32) -> EscConfig {
let mut cfg = EscConfig::gm3508(format!("m{esc_id}"), esc_id, direction); EscConfig::gm3508(format!("m{esc_id}"), esc_id, direction)
cfg.kp = 8.0; }
cfg.ki = 2.0;
cfg.kd = 0.0; fn tuned_gains() -> VelocityGains {
cfg.feedforward = 16384.0 / 9254.0; VelocityGains::new(8.0, 2.0, 0.0, 16384.0 / 9254.0)
cfg }
fn spawn_bus(
link: CanLink,
configs: Vec<EscConfig>,
options: BusOptions,
) -> Result<esc_bus::EscBusHandle<MotorPolicy>, EscBusError> {
let mut policy = MotorPolicy::new();
for config in &configs {
policy.configure(config.esc_id, config.current_limit, tuned_gains());
}
EscBus::spawn(link, configs, options, policy)
} }
/// 轮询直到条件满足或超时(毫秒,仿真时间)。 /// 轮询直到条件满足或超时(毫秒,仿真时间)。
@@ -93,7 +104,7 @@ fn slot_current(frame: &CanFrame, slot: usize) -> i16 {
async fn velocity_loop_converges() { async fn velocity_loop_converges() {
let (bus_link, plant_link) = CanLink::pair(512); let (bus_link, plant_link) = CanLink::pair(512);
let plant = spawn_plant(plant_link, &[1], Duration::from_millis(1)); let plant = spawn_plant(plant_link, &[1], Duration::from_millis(1));
let bus = EscBus::spawn(bus_link, vec![tuned(1, 1.0)], BusOptions::default()).unwrap(); let bus = spawn_bus(bus_link, vec![tuned(1, 1.0)], BusOptions::default()).unwrap();
let motor = bus.motor(1).unwrap().clone(); let motor = bus.motor(1).unwrap().clone();
motor.set_velocity(3000.0); motor.set_velocity(3000.0);
@@ -121,7 +132,7 @@ async fn reversed_motor_converges_in_physical_frame() {
// direction = -1:物理 +2000 rpm ⇔ 电调侧 -2000 rpm。 // direction = -1:物理 +2000 rpm ⇔ 电调侧 -2000 rpm。
let (bus_link, plant_link) = CanLink::pair(512); let (bus_link, plant_link) = CanLink::pair(512);
let plant = spawn_plant(plant_link, &[2], Duration::from_millis(1)); let plant = spawn_plant(plant_link, &[2], Duration::from_millis(1));
let bus = EscBus::spawn(bus_link, vec![tuned(2, -1.0)], BusOptions::default()).unwrap(); let bus = spawn_bus(bus_link, vec![tuned(2, -1.0)], BusOptions::default()).unwrap();
let motor = bus.motor(2).unwrap().clone(); let motor = bus.motor(2).unwrap().clone();
motor.set_velocity(2000.0); motor.set_velocity(2000.0);
@@ -143,7 +154,7 @@ async fn reversed_motor_converges_in_physical_frame() {
async fn motor_without_feedback_commands_zero_current() { async fn motor_without_feedback_commands_zero_current() {
let (bus_link, mut peer) = CanLink::pair(64); let (bus_link, mut peer) = CanLink::pair(64);
// 不接 plant → 永远没有反馈。 // 不接 plant → 永远没有反馈。
let bus = EscBus::spawn(bus_link, vec![tuned(1, 1.0)], BusOptions::default()).unwrap(); let bus = spawn_bus(bus_link, vec![tuned(1, 1.0)], BusOptions::default()).unwrap();
bus.motor(1).unwrap().set_velocity(5000.0); bus.motor(1).unwrap().set_velocity(5000.0);
for _ in 0..5 { for _ in 0..5 {
@@ -159,7 +170,7 @@ async fn current_limit_clamps_saturation() {
let (bus_link, mut peer) = CanLink::pair(64); let (bus_link, mut peer) = CanLink::pair(64);
let mut cfg = tuned(1, 1.0); let mut cfg = tuned(1, 1.0);
cfg.current_limit = 5000; cfg.current_limit = 5000;
let bus = EscBus::spawn(bus_link, vec![cfg], BusOptions::default()).unwrap(); let bus = spawn_bus(bus_link, vec![cfg], BusOptions::default()).unwrap();
send_feedback(&peer, &[1]).await; send_feedback(&peer, &[1]).await;
bus.motor(1).unwrap().set_velocity(30000.0); // 不可达目标 → 深度饱和 bus.motor(1).unwrap().set_velocity(30000.0); // 不可达目标 → 深度饱和
@@ -203,7 +214,7 @@ fn spawn_feedback(yield_tx: mpsc::Sender<CanFrame>, esc_ids: Vec<u8>) -> JoinHan
async fn command_lease_expires_and_renews() { async fn command_lease_expires_and_renews() {
let (bus_link, mut peer) = CanLink::pair(256); let (bus_link, mut peer) = CanLink::pair(256);
let feeder = spawn_feedback(peer.outgoing_tx.clone(), vec![1]); let feeder = spawn_feedback(peer.outgoing_tx.clone(), vec![1]);
let bus = EscBus::spawn(bus_link, vec![tuned(1, 1.0)], BusOptions::default()).unwrap(); let bus = spawn_bus(bus_link, vec![tuned(1, 1.0)], BusOptions::default()).unwrap();
let motor = bus.motor(1).unwrap().clone(); let motor = bus.motor(1).unwrap().clone();
// 1) 下发后 30ms 内(< 50ms 租约)应看到非零电流。 // 1) 下发后 30ms 内(< 50ms 租约)应看到非零电流。
@@ -248,7 +259,7 @@ async fn command_lease_expires_and_renews() {
async fn current_commands_route_to_group_slots() { async fn current_commands_route_to_group_slots() {
let (bus_link, mut peer) = CanLink::pair(256); let (bus_link, mut peer) = CanLink::pair(256);
let configs = (1..=5).map(|id| tuned(id, 1.0)).collect(); let configs = (1..=5).map(|id| tuned(id, 1.0)).collect();
let bus = EscBus::spawn(bus_link, configs, BusOptions::default()).unwrap(); let bus = spawn_bus(bus_link, configs, BusOptions::default()).unwrap();
bus.motor(3).unwrap().set_effort_raw(1234); bus.motor(3).unwrap().set_effort_raw(1234);
bus.motor(5).unwrap().set_effort_raw(-567); bus.motor(5).unwrap().set_effort_raw(-567);
@@ -306,7 +317,7 @@ fn try_find_frame(peer: &mut CanLink, pred: impl Fn(&CanFrame) -> bool) -> bool
#[tokio::test] #[tokio::test]
async fn feedback_updates_state_with_direction() { async fn feedback_updates_state_with_direction() {
let (bus_link, peer) = CanLink::pair(64); let (bus_link, peer) = CanLink::pair(64);
let bus = EscBus::spawn(bus_link, vec![tuned(2, -1.0)], BusOptions::default()).unwrap(); let bus = spawn_bus(bus_link, vec![tuned(2, -1.0)], BusOptions::default()).unwrap();
// 两帧正向跨圈:8000 → 100,电调侧圈数 ≈ 1.0122。 // 两帧正向跨圈:8000 → 100,电调侧圈数 ≈ 1.0122。
let fb = EscFeedback { angle_raw: 8000, speed_rpm: 600, current_raw: -300, temperature_c: 47 }; let fb = EscFeedback { angle_raw: 8000, speed_rpm: 600, current_raw: -300, temperature_c: 47 };
@@ -337,7 +348,7 @@ async fn feedback_updates_state_with_direction() {
async fn shutdown_stops_tasks_and_closes_link() { async fn shutdown_stops_tasks_and_closes_link() {
let (bus_link, mut peer) = CanLink::pair(128); let (bus_link, mut peer) = CanLink::pair(128);
// 不接 plant,直接验证任务生命周期。 // 不接 plant,直接验证任务生命周期。
let bus = EscBus::spawn(bus_link, vec![tuned(1, 1.0)], BusOptions::default()).unwrap(); let bus = spawn_bus(bus_link, vec![tuned(1, 1.0)], BusOptions::default()).unwrap();
bus.motor(1).unwrap().set_velocity(1000.0); bus.motor(1).unwrap().set_velocity(1000.0);
sleep(Duration::from_millis(20)).await; sleep(Duration::from_millis(20)).await;
// 有命令帧在流动。 // 有命令帧在流动。
@@ -358,17 +369,14 @@ async fn shutdown_stops_tasks_and_closes_link() {
#[test] #[test]
fn spawn_rejects_bad_configs() { fn spawn_rejects_bad_configs() {
let link = || CanLink::pair(8).0; let link = || CanLink::pair(8).0;
assert_eq!(spawn_bus(link(), vec![], BusOptions::default()).err(), Some(EscBusError::NoMotors));
assert_eq!( assert_eq!(
EscBus::spawn(link(), vec![], BusOptions::default()).err(), spawn_bus(link(), vec![tuned(2, 1.0), tuned(2, -1.0)], BusOptions::default()).err(),
Some(EscBusError::NoMotors)
);
assert_eq!(
EscBus::spawn(link(), vec![tuned(2, 1.0), tuned(2, -1.0)], BusOptions::default()).err(),
Some(EscBusError::DuplicateEscId(2)) Some(EscBusError::DuplicateEscId(2))
); );
let mut bad = tuned(1, 1.0); let mut bad = tuned(1, 1.0);
bad.direction = 0.0; bad.direction = 0.0;
let err = EscBus::spawn(link(), vec![bad], BusOptions::default()).err(); let err = spawn_bus(link(), vec![bad], BusOptions::default()).err();
assert!( assert!(
matches!(err, Some(EscBusError::InvalidConfig { .. })), matches!(err, Some(EscBusError::InvalidConfig { .. })),
"expected InvalidConfig, got {err:?}" "expected InvalidConfig, got {err:?}"
@@ -386,22 +394,22 @@ async fn velocity_gains_can_be_hot_swapped() {
let plant = spawn_plant(plant_link, &[1], Duration::from_millis(1)); let plant = spawn_plant(plant_link, &[1], Duration::from_millis(1));
let cfg = tuned(1, 1.0); let cfg = tuned(1, 1.0);
let limit = i32::from(cfg.current_limit); let limit = i32::from(cfg.current_limit);
let bus = EscBus::spawn(bus_link, vec![cfg.clone()], BusOptions::default()).unwrap(); let bus = spawn_bus(bus_link, vec![cfg.clone()], BusOptions::default()).unwrap();
let motor = bus.motor(1).unwrap().clone(); let motor = bus.motor(1).unwrap().clone();
// ① 初始增益来自 EscConfig(含前馈)。 // ① 初始增益来自 EscConfig(含前馈)。
assert_eq!(motor.velocity_gains(), cfg.velocity_gains()); assert_eq!(bus.with_policy(|policy| policy.velocity_gains(motor.id()).unwrap()), tuned_gains());
// 让旧增益的积分先积累起来(大静差 + 深度饱和)。 // 让旧增益的积分先积累起来(大静差 + 深度饱和)。
motor.set_velocity(9000.0); motor.set_velocity(9000.0);
hold(&motor, 9000.0, 200).await; hold(&motor, 9000.0, 200).await;
let stale = motor.velocity_gains(); let stale = bus.with_policy(|policy| policy.velocity_gains(motor.id()).unwrap());
// ② 热更新:控制器被重建。 // ② 热更新:控制器被重建。
let swapped = VelocityGains::new(1.0, 0.0, 0.0, 0.0); let swapped = VelocityGains::new(1.0, 0.0, 0.0, 0.0);
motor.set_velocity_gains(swapped); bus.with_policy(|policy| policy.set_velocity_gains(motor.id(), swapped));
assert_eq!(motor.velocity_gains(), swapped); assert_eq!(bus.with_policy(|policy| policy.velocity_gains(motor.id()).unwrap()), swapped);
assert_ne!(motor.velocity_gains(), stale); assert_ne!(bus.with_policy(|policy| policy.velocity_gains(motor.id()).unwrap()), stale);
// 新增益生效:误差 e 时输出 = kp·e = e(无前馈、无积分),并仍被限幅夹住。 // 新增益生效:误差 e 时输出 = kp·e = e(无前馈、无积分),并仍被限幅夹住。
motor.set_velocity(30_000.0); motor.set_velocity(30_000.0);
@@ -412,7 +420,11 @@ async fn velocity_gains_can_be_hot_swapped() {
// ③ 限幅仍是启动时的 current_limit:热更新不得放宽安全边界。 // ③ 限幅仍是启动时的 current_limit:热更新不得放宽安全边界。
let effort = i32::from(motor.state().effort_raw); let effort = i32::from(motor.state().effort_raw);
assert_eq!(effort, limit, "饱和值必须正好是 current_limit"); assert_eq!(effort, limit, "饱和值必须正好是 current_limit");
assert_eq!(motor.velocity_gains(), swapped, "热更新后增益保持不变"); assert_eq!(
bus.with_policy(|policy| policy.velocity_gains(motor.id()).unwrap()),
swapped,
"热更新后增益保持不变"
);
plant.abort(); plant.abort();
bus.shutdown().await; bus.shutdown().await;
@@ -1,6 +1,6 @@
//! 语义一致性:同步评估 harness 与异步 `EscBus` 闭环必须给出同一个答案。 //! 语义一致性:同步评估 harness 与异步 `EscBus` 闭环必须给出同一个答案。
//! //!
//! 自动整定跑在 [`dji_esc_sim::simulate`](同步、无通道)上,而实车跑的是 //! 自动整定跑在 [`sim_harness::simulate`](同步、无通道)上,而实车跑的是
//! `EscBus` 的 1 kHz 发送循环。若两者的限幅 / 物理帧 / 控制器语义漂移, //! `EscBus` 的 1 kHz 发送循环。若两者的限幅 / 物理帧 / 控制器语义漂移,
//! 整定出来的增益就只是"对仿真有效"。本测试把两条路径放在同一组参数与 //! 整定出来的增益就只是"对仿真有效"。本测试把两条路径放在同一组参数与
//! 同一条场景下比较稳态与 effort。 //! 同一条场景下比较稳态与 effort。
@@ -12,9 +12,13 @@
use std::time::Duration; use std::time::Duration;
use dji_esc::{BusOptions, CanLink, EscBus, EscConfig}; use can::CanLink;
use dji_esc_sim::{LoopLimits, PlantParams, Scenario, simulate, spawn_plant}; use esc_bus::{BusOptions, EscBus, EscConfig};
use motor_core::{MotorFeedback, VelocityDrive, VelocityGains}; use experiment::Scenario;
use motor::{MotorFeedback, VelocityDrive};
use motor_control::{MotorPolicy, VelocityGains};
use motor_model::PlantParams;
use sim_harness::{LoopLimits, simulate, spawn_plant};
use tokio::time::sleep; use tokio::time::sleep;
/// 与 `PlantParams::gm3508` 匹配的增益(ff = 16384 / 9254)。 /// 与 `PlantParams::gm3508` 匹配的增益(ff = 16384 / 9254)。
@@ -40,12 +44,11 @@ async fn async_steady_state() -> (f32, f32) {
let (bus_link, plant_link) = CanLink::pair(512); let (bus_link, plant_link) = CanLink::pair(512);
let plant = spawn_plant(plant_link, &[1], Duration::from_millis(1)); let plant = spawn_plant(plant_link, &[1], Duration::from_millis(1));
let mut cfg = EscConfig::gm3508("front_left", 1, 1.0); let mut cfg = EscConfig::gm3508("front_left", 1, 1.0);
cfg.kp = GAINS.kp;
cfg.ki = GAINS.ki;
cfg.kd = GAINS.kd;
cfg.feedforward = GAINS.feedforward;
cfg.current_limit = LoopLimits::gm3508().current_limit_raw; cfg.current_limit = LoopLimits::gm3508().current_limit_raw;
let bus = EscBus::spawn(bus_link, vec![cfg], BusOptions::default()).expect("bus must start"); let mut policy = MotorPolicy::new();
policy.configure(cfg.esc_id, cfg.current_limit, GAINS);
let bus =
EscBus::spawn(bus_link, vec![cfg], BusOptions::default(), policy).expect("bus must start");
let motor = bus.motor(1).expect("motor 1 exists").clone(); let motor = bus.motor(1).expect("motor 1 exists").clone();
// 命令是租约:逐拍重发(5 ms 节拍模拟 1 kHz 控制循环)。 // 命令是租约:逐拍重发(5 ms 节拍模拟 1 kHz 控制循环)。
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "experiment"
description = "Shared experiment scenarios, traces, windows, and metrics"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
[dev-dependencies]
approx = { workspace = true }
+747
View File
@@ -0,0 +1,747 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub mod scenario {
//! 场景定义:设定值曲线 + 采样节拍。
//!
//! 场景与具体被控对象解耦:同一场景可在 [`crate::PlantMotor`](离线)、
//! `CanLink` 上的 `EscBus` 闭环(在线)与真机台架(`chassis-bench`)上重放,
//! 因此整定结果可以跨后端比较。
/// 设定值曲线。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SetpointProfile {
/// 阶跃:`t = 0` 直接给目标(忽略 `ramp_s`)。
Step,
/// 斜坡 + 保持:`ramp_s` 内线性升到目标,随后保持 `hold_s`。
RampAndHold,
/// 阶梯:在 `ramp_s + hold_s` 内均分 `levels` 级,逐级跳变到目标。
Staircase {
/// 阶梯数(≥ 1)。
levels: u32,
},
/// 升 → 保持 → 降 → 保持(回零),总时长 `2 × (ramp_s + hold_s)`。
UpDown,
}
/// 一次试验的设定值与时间规格。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Scenario {
/// 控制 / 采样节拍(秒)。
pub dt_s: f32,
/// 斜坡时间(秒);`0` 表示瞬时。
pub ramp_s: f32,
/// 保持时间(秒)。
pub hold_s: f32,
/// 目标转子转速(rpm)。
pub target_rpm: f32,
/// 设定值曲线。
pub profile: SetpointProfile,
}
impl Scenario {
/// 默认控制节拍(1 kHz,与 C620 命令周期一致)。
pub const DEFAULT_DT_S: f32 = 0.001;
/// 阶跃 + 保持。
#[must_use]
pub const fn step(target_rpm: f32, hold_s: f32) -> Self {
Self {
dt_s: Self::DEFAULT_DT_S,
ramp_s: 0.0,
hold_s,
target_rpm,
profile: SetpointProfile::Step,
}
}
/// 斜坡 + 保持(台架默认整定场景)。
#[must_use]
pub const fn ramp_and_hold(target_rpm: f32, ramp_s: f32, hold_s: f32) -> Self {
Self {
dt_s: Self::DEFAULT_DT_S,
ramp_s,
hold_s,
target_rpm,
profile: SetpointProfile::RampAndHold,
}
}
/// 换一个采样节拍。
#[must_use]
pub const fn with_dt_s(mut self, dt_s: f32) -> Self {
self.dt_s = dt_s;
self
}
/// 换一条设定值曲线。
#[must_use]
pub const fn with_profile(mut self, profile: SetpointProfile) -> Self {
self.profile = profile;
self
}
/// 场景总时长(秒)。
#[must_use]
pub fn total_s(&self) -> f32 {
match self.profile {
SetpointProfile::Step => self.hold_s,
SetpointProfile::RampAndHold | SetpointProfile::Staircase { .. } => {
self.ramp_s + self.hold_s
}
SetpointProfile::UpDown => 2.0 * (self.ramp_s + self.hold_s),
}
}
/// 场景采样点数(`dt_s` 非法时返回 1,由调用方做参数校验)。
#[must_use]
pub fn samples(&self) -> usize {
let total = self.total_s();
let dt_ok = self.dt_s.is_finite() && self.dt_s > 0.0;
let total_ok = total.is_finite() && total > 0.0;
if !dt_ok || !total_ok {
return 1;
}
((total / self.dt_s).round() as usize).max(1)
}
/// `t_s` 时刻的设定值(rpm)。
#[must_use]
pub fn setpoint_at(&self, t_s: f32) -> f32 {
let target = self.target_rpm;
let ramp = self.ramp_s;
match self.profile {
SetpointProfile::Step => target,
SetpointProfile::RampAndHold => {
if ramp > 0.0 && t_s < ramp {
target * (t_s / ramp)
} else {
target
}
}
SetpointProfile::Staircase { levels } => {
let levels = levels.max(1);
let total = ramp + self.hold_s;
if total <= 0.0 {
return target;
}
let rung_s = total / levels as f32;
let index = (t_s / rung_s).floor().max(0.0) as u32;
target * ((index.min(levels - 1) + 1) as f32 / levels as f32)
}
SetpointProfile::UpDown => {
let up_end = ramp;
let hold_end = up_end + self.hold_s;
let down_end = hold_end + ramp;
if t_s < up_end {
if ramp > 0.0 { target * (t_s / ramp) } else { target }
} else if t_s < hold_end {
target
} else if t_s < down_end {
let progress = (t_s - hold_end) / ramp;
target * (1.0 - progress)
} else {
0.0
}
}
}
}
/// 稳态评价窗口的起点(秒):斜坡 / 阶梯结束、进入最后一段保持时。
#[must_use]
pub fn steady_start_s(&self) -> f32 {
match self.profile {
SetpointProfile::Step => 0.0,
SetpointProfile::RampAndHold => self.ramp_s,
SetpointProfile::Staircase { levels } => {
let level_s = self.level_s(levels);
((levels.max(1) - 1) as f32) * level_s
}
SetpointProfile::UpDown => self.ramp_s + self.hold_s,
}
}
/// 稳态评价窗口的终点(秒)。
#[must_use]
pub fn steady_end_s(&self) -> f32 {
match self.profile {
// 升降场景只评价上升段后的保持段。
SetpointProfile::UpDown => self.ramp_s + self.hold_s,
_ => self.total_s(),
}
}
/// 阶梯的单级时长(秒)。
fn level_s(&self, levels: u32) -> f32 {
let levels = levels.max(1);
let total = self.ramp_s + self.hold_s;
if total > 0.0 { total / levels as f32 } else { 0.0 }
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 浮点断言辅助:避免 `float_cmp`,同时给出可读的失败信息。
#[track_caller]
fn assert_close(actual: f32, expected: f32) {
assert!((actual - expected).abs() < 1e-3, "actual {actual}, expected {expected}");
}
#[test]
fn step_is_immediate_and_lasts_hold() {
let s = Scenario::step(1000.0, 0.5);
assert!((s.total_s() - 0.5).abs() < 1e-6);
assert_close(s.setpoint_at(0.0), 1000.0);
assert_close(s.setpoint_at(0.4), 1000.0);
assert_eq!(s.samples(), 500);
}
#[test]
fn ramp_is_linear_then_holds() {
let s = Scenario::ramp_and_hold(1000.0, 0.2, 1.0);
assert_close(s.setpoint_at(0.0), 0.0);
assert!((s.setpoint_at(0.1) - 500.0).abs() < 1e-3);
assert_close(s.setpoint_at(0.2), 1000.0);
assert_close(s.setpoint_at(1.1), 1000.0);
assert!((s.total_s() - 1.2).abs() < 1e-6);
}
#[test]
fn staircase_rises_in_equal_levels() {
let s = Scenario::ramp_and_hold(1000.0, 0.0, 1.0)
.with_profile(SetpointProfile::Staircase { levels: 4 });
assert!((s.setpoint_at(0.0) - 250.0).abs() < 1e-3);
assert!((s.setpoint_at(0.3) - 500.0).abs() < 1e-3);
assert!((s.setpoint_at(0.6) - 750.0).abs() < 1e-3);
assert!((s.setpoint_at(0.99) - 1000.0).abs() < 1e-3);
// 越界采样被夹在最后一级。
assert_close(s.setpoint_at(5.0), 1000.0);
}
#[test]
fn up_down_returns_to_zero() {
let s = Scenario::ramp_and_hold(600.0, 0.1, 0.2).with_profile(SetpointProfile::UpDown);
assert!((s.total_s() - 0.6).abs() < 1e-6);
assert!((s.setpoint_at(0.05) - 300.0).abs() < 1e-3);
assert_close(s.setpoint_at(0.2), 600.0);
assert!((s.setpoint_at(0.35) - 300.0).abs() < 1e-3);
assert_close(s.setpoint_at(0.59), 0.0);
}
#[test]
fn degenerate_scenario_reports_one_sample() {
let s = Scenario::step(1.0, 0.0);
assert_eq!(s.samples(), 1);
let s = Scenario::step(1.0, 1.0).with_dt_s(0.0);
assert_eq!(s.samples(), 1);
}
}
}
pub use scenario::*;
pub mod trace {
//! 试验轨迹:一次场景重放的全部采样。
/// 一条完整试验轨迹(各列等长,索引即采样序号)。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Trace {
/// 采样时刻(秒)。
pub t_s: Vec<f32>,
/// 该时刻的设定值(rpm)。
pub setpoint_rpm: Vec<f32>,
/// 该时刻的实测转速(rpm,物理方向)。
pub rpm: Vec<f32>,
/// 该时刻下发的 effort(raw,已限幅)。
pub effort_raw: Vec<i16>,
/// 该时刻控制器输出是否贴着限幅(饱和)。
pub saturated: Vec<bool>,
}
impl Trace {
/// 预留容量的空轨迹。
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
Self {
t_s: Vec::with_capacity(capacity),
setpoint_rpm: Vec::with_capacity(capacity),
rpm: Vec::with_capacity(capacity),
effort_raw: Vec::with_capacity(capacity),
saturated: Vec::with_capacity(capacity),
}
}
/// 追加一个采样。
pub fn push(
&mut self,
t_s: f32,
setpoint_rpm: f32,
rpm: f32,
effort_raw: i16,
saturated: bool,
) {
self.t_s.push(t_s);
self.setpoint_rpm.push(setpoint_rpm);
self.rpm.push(rpm);
self.effort_raw.push(effort_raw);
self.saturated.push(saturated);
}
/// 采样数。
#[must_use]
pub fn len(&self) -> usize {
self.rpm.len()
}
/// 是否为空。
#[must_use]
pub fn is_empty(&self) -> bool {
self.rpm.is_empty()
}
/// 最后一段采样(稳态窗口用;越界时返回空切片)。
#[must_use]
pub fn window(&self, from_s: f32, to_s: f32) -> Window<'_> {
let start = self.t_s.partition_point(|&t| t < from_s);
let end = self.t_s.partition_point(|&t| t <= to_s);
let end = end.max(start);
Window { trace: self, range: start..end }
}
/// 全轨迹最大转速(rpm)。
#[must_use]
pub fn peak_rpm(&self) -> f32 {
self.rpm.iter().copied().fold(0.0f32, |acc, v| acc.max(v.abs()))
}
/// 全轨迹最小 / 最大转速(rpm)。
#[must_use]
pub fn rpm_range(&self) -> (f32, f32) {
let min = self.rpm.iter().copied().fold(f32::INFINITY, f32::min);
let max = self.rpm.iter().copied().fold(f32::NEG_INFINITY, f32::max);
if self.rpm.is_empty() { (0.0, 0.0) } else { (min, max) }
}
/// 饱和采样占比(0..=1)。
#[must_use]
pub fn saturated_fraction(&self) -> f32 {
if self.saturated.is_empty() {
return 0.0;
}
let count = self.saturated.iter().filter(|&&s| s).count();
count as f32 / self.saturated.len() as f32
}
/// effort 符号翻转次数(振荡 / 抖振指标)。
#[must_use]
pub fn effort_reversals(&self) -> usize {
let mut reversals = 0usize;
let mut last_sign = 0i16;
for &raw in &self.effort_raw {
let sign = raw.signum();
if sign != 0 {
if last_sign != 0 && sign != last_sign {
reversals += 1;
}
last_sign = sign;
}
}
reversals
}
/// 全部列长度一致(不变量自检)。
#[must_use]
pub fn is_consistent(&self) -> bool {
self.t_s.len() == self.rpm.len()
&& self.setpoint_rpm.len() == self.rpm.len()
&& self.effort_raw.len() == self.rpm.len()
&& self.saturated.len() == self.rpm.len()
}
}
/// 轨迹上的一个时间窗口视图。
#[derive(Debug, Clone)]
pub struct Window<'a> {
trace: &'a Trace,
range: std::ops::Range<usize>,
}
impl Window<'_> {
/// 窗口内采样数。
#[must_use]
pub fn len(&self) -> usize {
self.range.len()
}
/// 窗口是否为空。
#[must_use]
pub fn is_empty(&self) -> bool {
self.range.is_empty()
}
/// 窗口内的实测转速。
#[must_use]
pub fn rpm(&self) -> &[f32] {
&self.trace.rpm[self.range.clone()]
}
/// 窗口内的设定值。
#[must_use]
pub fn setpoint_rpm(&self) -> &[f32] {
&self.trace.setpoint_rpm[self.range.clone()]
}
/// 窗口内的 effort。
#[must_use]
pub fn effort_raw(&self) -> &[i16] {
&self.trace.effort_raw[self.range.clone()]
}
/// 窗口内的时刻。
#[must_use]
pub fn t_s(&self) -> &[f32] {
&self.trace.t_s[self.range.clone()]
}
/// 窗口内转速均值。
#[must_use]
pub fn mean_rpm(&self) -> f32 {
let values = self.rpm();
if values.is_empty() {
return 0.0;
}
values.iter().sum::<f32>() / values.len() as f32
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_trace() -> Trace {
let mut trace = Trace::with_capacity(8);
for i in 0..8 {
let t = i as f32 * 0.1;
trace.push(t, 100.0, t * 100.0, if i % 2 == 0 { 10 } else { -10 }, i > 5);
}
trace
}
#[test]
fn push_keeps_columns_aligned() {
let trace = sample_trace();
assert_eq!(trace.len(), 8);
assert!(trace.is_consistent());
}
#[test]
fn window_selects_half_open_range() {
let trace = sample_trace();
let window = trace.window(0.2, 0.4);
assert_eq!(window.len(), 3); // t = 0.2, 0.3, 0.4
assert_eq!(window.t_s(), &[0.2, 0.3, 0.4]);
assert!((window.mean_rpm() - 30.0).abs() < 1e-3);
}
#[test]
fn window_out_of_range_is_empty() {
let trace = sample_trace();
let window = trace.window(5.0, 6.0);
assert!(window.is_empty());
assert!(window.mean_rpm().abs() < f32::EPSILON);
}
#[test]
fn peak_and_range_are_absolute() {
let trace = sample_trace();
assert!((trace.peak_rpm() - 70.0).abs() < 1e-3);
assert_eq!(trace.rpm_range(), (0.0, 70.0));
}
#[test]
fn saturated_fraction_counts_flags() {
let trace = sample_trace();
assert!((trace.saturated_fraction() - 0.25).abs() < 1e-6);
}
#[test]
fn effort_reversals_counts_sign_changes() {
let trace = sample_trace();
// +,−,+,−,+,−,+,−:每次翻转都计数(首个样本建立方向)。
assert_eq!(trace.effort_reversals(), 7);
}
}
}
pub use trace::*;
pub mod metrics {
//! 步响应指标:把一条 [`Trace`] 折成可比较、可解释的标量。
//!
//! 指标口径对**所有后端**(离线 harness、真机台架)一致——整定结果能不能
//! 从仿真迁移到实车,取决于两条路径是否用同一把尺子量。
use crate::{Scenario, Trace};
/// 稳态判据的相对带宽(±2% of target)。
pub const SETTLING_BAND: f32 = 0.02;
/// 稳态窗口占轨迹总时长的比例(从 75% 处开始)。
///
/// 与场景自带的 `steady_start_s()` 取**更晚**者:阶跃场景没有显式斜坡,
/// 若不设下限就会把整条上升段也算进"稳态",任何真实响应都会被判成未收敛。
pub const STEADY_FRACTION: f32 = 0.75;
/// 指标最少需要的采样数(样本太少时一切统计都无意义)。
pub const MIN_SAMPLES: usize = 8;
/// 一条步响应轨迹的指标集合。
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct StepMetrics {
/// 上升时间 10% → 90%(秒);未到达时等于轨迹总时长。
pub rise_time_s: f32,
/// 超调量(%,相对目标;负值截为 0)。
pub overshoot_pct: f32,
/// 调节时间:最后一次离开 ±2% 带的时刻(秒)。
pub settling_time_s: f32,
/// 是否在稳态窗口内保持 ±2% 带(即真正收敛)。
pub settled: bool,
/// 稳态误差(%,相对目标)。
pub ss_error_pct: f32,
/// 绝对误差积分 ∫|e|dt(归一化前的原始值)。
pub iae: f32,
/// 误差平方积分 ∫e²dt。
pub ise: f32,
/// 时间加权绝对误差积分 ∫t|e|dt。
pub itae: f32,
/// 峰值 effort(raw,绝对值)。
pub peak_effort_raw: f32,
/// effort 均方根(raw)。
pub rms_effort_raw: f32,
/// effort 符号翻转次数(抖振)。
pub effort_reversals: usize,
/// 饱和采样占比(%)。
pub saturated_pct: f32,
/// 全过程最大转速(rpm,绝对值)。
pub max_rpm: f32,
/// 稳态窗口内的平均转速(rpm)。
pub steady_rpm: f32,
}
impl StepMetrics {
/// 从轨迹计算指标;样本不足或目标转速为 0 时返回 `None`。
#[must_use]
pub fn from_trace(trace: &Trace, scenario: &Scenario) -> Option<Self> {
let target = scenario.target_rpm;
if trace.len() < MIN_SAMPLES || trace.len() != trace.t_s.len() {
return None;
}
if !target.is_finite() || target.abs() < f32::EPSILON {
return None;
}
let sign = if target < 0.0 { -1.0 } else { 1.0 };
let magnitude = target.abs();
// 上升时间:10% / 90% 两个阈值首次被跨过的时刻。
let t10 = trace
.rpm
.iter()
.zip(&trace.t_s)
.find(|(rpm, _)| **rpm * sign >= 0.1 * magnitude)
.map(|(_, &t)| t);
let t90 = trace
.rpm
.iter()
.zip(&trace.t_s)
.find(|(rpm, _)| **rpm * sign >= 0.9 * magnitude)
.map(|(_, &t)| t);
let total_s = trace.t_s.last().copied().unwrap_or(0.0);
let rise_time_s = match (t10, t90) {
(Some(t10), Some(t90)) if t90 >= t10 => t90 - t10,
// 没有跨过 90%:用剩余时长表达"到不了",让成本函数自然惩罚它。
_ => total_s,
};
// 超调:朝着目标方向的过冲。
let peak_directed =
trace.rpm.iter().map(|rpm| *rpm * sign).fold(f32::NEG_INFINITY, f32::max);
let overshoot_pct = ((peak_directed - magnitude) / magnitude * 100.0).max(0.0);
// 稳态窗口:场景给出的起点与"最后 25% 时长"取更晚者。
let steady_start = (total_s * STEADY_FRACTION).max(scenario.steady_start_s());
let steady_end = scenario.steady_end_s().max(steady_start);
let window = trace.window(steady_start, steady_end);
let steady_rpm = window.mean_rpm();
let ss_error_pct = ((steady_rpm - target).abs() / magnitude) * 100.0;
// 调节时间:最后一次离开 ±band 的时刻;此后一直留在带内才算收敛。
let band = SETTLING_BAND * magnitude;
let mut settling_time_s = 0.0f32;
for (rpm, &t) in trace.rpm.iter().zip(&trace.t_s) {
if (*rpm - target).abs() > band {
settling_time_s = t;
}
}
// 收敛 = 最后一次离开带宽的时刻已经进入稳态窗口(此后一直留在带内)。
let settled = settling_time_s <= steady_start && !window.is_empty();
// 误差积分(全过程,按 dt 累加)。
let mut abs_error_area = 0.0f32;
let mut squared_error_area = 0.0f32;
let mut time_weighted_area = 0.0f32;
for index in 1..trace.len() {
let dt = (trace.t_s[index] - trace.t_s[index - 1]).max(0.0);
let error = trace.setpoint_rpm[index] - trace.rpm[index];
abs_error_area += error.abs() * dt;
squared_error_area += error * error * dt;
time_weighted_area += trace.t_s[index] * error.abs() * dt;
}
let peak_effort_raw =
trace.effort_raw.iter().map(|&raw| f32::from(raw).abs()).fold(0.0f32, f32::max);
let rms_effort_raw = if trace.effort_raw.is_empty() {
0.0
} else {
(trace.effort_raw.iter().map(|&raw| f32::from(raw) * f32::from(raw)).sum::<f32>()
/ trace.effort_raw.len() as f32)
.sqrt()
};
Some(Self {
rise_time_s,
overshoot_pct,
settling_time_s,
settled,
ss_error_pct,
iae: abs_error_area,
ise: squared_error_area,
itae: time_weighted_area,
peak_effort_raw,
rms_effort_raw,
effort_reversals: trace.effort_reversals(),
saturated_pct: trace.saturated_fraction() * 100.0,
max_rpm: trace.peak_rpm(),
steady_rpm,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scenario() -> Scenario {
Scenario::step(1000.0, 1.0)
}
/// 合成一条轨迹:一阶响应 + 可选超调 + 可选末端偏差。
fn synthetic(alpha: f32, overshoot: f32, final_offset: f32) -> Trace {
let scenario = scenario();
let dt = scenario.dt_s;
let steps = scenario.samples();
let mut trace = Trace::with_capacity(steps);
let mut rpm = 0.0f32;
for step in 0..steps {
let t = step as f32 * dt;
let target = scenario.setpoint_at(t);
rpm += (target - rpm) * alpha;
let value = if t > 0.3 {
rpm + overshoot * 1000.0 * (-(t - 0.3) * 20.0).exp() + final_offset
} else {
rpm
};
trace.push(t, target, value, 0, false);
}
trace
}
#[test]
fn first_order_response_has_no_overshoot_and_settles() {
let trace = synthetic(0.02, 0.0, 0.0);
let metrics = StepMetrics::from_trace(&trace, &scenario()).unwrap();
assert!(metrics.overshoot_pct < 0.5, "overshoot = {}", metrics.overshoot_pct);
assert!(metrics.settled, "一阶响应必须收敛");
assert!(metrics.rise_time_s > 0.0 && metrics.rise_time_s < 1.0);
assert!(metrics.ss_error_pct < 1.0, "ss = {}", metrics.ss_error_pct);
assert!(metrics.iae > 0.0 && metrics.itae > 0.0);
}
#[test]
fn overshoot_is_measured_against_the_target() {
let trace = synthetic(0.05, 0.2, 0.0);
let metrics = StepMetrics::from_trace(&trace, &scenario()).unwrap();
assert!(
(metrics.overshoot_pct - 20.0).abs() < 3.0,
"overshoot = {}",
metrics.overshoot_pct
);
}
#[test]
fn steady_state_offset_shows_up() {
let trace = synthetic(0.02, 0.0, 50.0);
let metrics = StepMetrics::from_trace(&trace, &scenario()).unwrap();
assert!((metrics.ss_error_pct - 5.0).abs() < 1.0, "ss = {}", metrics.ss_error_pct);
// 5% 的末端偏差超出 ±2% 带 → 不算收敛。
assert!(!metrics.settled);
}
#[test]
fn non_converging_response_is_flagged() {
// 误差始终不减(alpha 很小),且稳态窗口内仍在带外。
let trace = synthetic(0.0005, 0.0, 0.0);
let metrics = StepMetrics::from_trace(&trace, &scenario()).unwrap();
assert!(!metrics.settled, "未收敛必须被标记");
assert!(metrics.ss_error_pct > 2.0);
}
#[test]
fn effort_statistics_and_saturation_are_reported() {
let mut trace = synthetic(0.02, 0.0, 0.0);
for (index, raw) in trace.effort_raw.iter_mut().enumerate() {
*raw = if index % 2 == 0 { 500 } else { -500 };
}
trace.saturated.fill(true);
let metrics = StepMetrics::from_trace(&trace, &scenario()).unwrap();
assert!((metrics.peak_effort_raw - 500.0).abs() < 1e-3);
assert!((metrics.rms_effort_raw - 500.0).abs() < 1e-3);
assert_eq!(metrics.effort_reversals, trace.len() - 1);
assert!((metrics.saturated_pct - 100.0).abs() < 1e-6);
}
#[test]
fn negative_targets_are_handled_symmetrically() {
let scenario = Scenario::step(-1000.0, 1.0);
let steps = scenario.samples();
let mut trace = Trace::with_capacity(steps);
let mut rpm = 0.0f32;
for step in 0..steps {
let t = step as f32 * scenario.dt_s;
let target = scenario.setpoint_at(t);
rpm += (target - rpm) * 0.02;
trace.push(t, target, rpm, 0, false);
}
let metrics = StepMetrics::from_trace(&trace, &scenario).unwrap();
assert!(metrics.settled);
assert!(metrics.ss_error_pct < 1.0);
assert!(metrics.overshoot_pct < 1.0);
}
#[test]
fn degenerate_inputs_are_rejected() {
let short = Trace {
t_s: vec![0.0, 0.001],
setpoint_rpm: vec![1.0, 1.0],
rpm: vec![0.0, 0.1],
effort_raw: vec![0, 0],
saturated: vec![false, false],
};
assert!(StepMetrics::from_trace(&short, &scenario()).is_none());
let zero_target = Scenario::step(0.0, 1.0);
let trace = synthetic(0.02, 0.0, 0.0);
assert!(StepMetrics::from_trace(&trace, &zero_target).is_none());
}
}
}
pub use metrics::*;
@@ -1,13 +1,13 @@
[package] [package]
name = "can-link" name = "imu"
description = "CAN 传输抽象:帧与链路通道(无协议知识)" description = "IMU data contract"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
[dependencies]
tokio = { workspace = true }
[lints] [lints]
workspace = true workspace = true
[dependencies]
+131
View File
@@ -0,0 +1,131 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! IMU 抽象:姿态数据与读取接口(ARCHITECTURE.md §5.1)。
//!
//! `driver crates::delta10` 为 DETA10 实现 [`Imu`];测试可用
//! `motor::testing` 风格的替身。
//!
//! # 单位与坐标系
//!
//! 与 ARCHITECTURE.md §5.1 的 [`ImuData`] 一致:
//!
//! | 字段 | 单位 | 坐标系 |
//! | --- | --- | --- |
//! | `accel` | m/s² | 机体系(x 前、y 左、z 上) |
//! | `gyro` | rad/s | 机体系 |
//! | `mag` | µT | 机体系 |
//! | `roll` / `pitch` / `yaw` | 度 | 机体 → 世界(yaw 逆时针为正) |
//!
//! 时间戳为单调时钟纳秒(见 [`crate::time`]),用于和电机反馈对齐。
/// 一帧 IMU 数据。
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct ImuData {
/// 加速度(m/s²,机体系 x/y/z)。
pub accel: [f32; 3],
/// 角速度(rad/s,机体系 x/y/z)。
pub gyro: [f32; 3],
/// 磁力计(µT,机体系 x/y/z);无磁力计时为 `[0.0; 3]`。
pub mag: [f32; 3],
/// 横滚角(度)。
pub roll: f32,
/// 俯仰角(度)。
pub pitch: f32,
/// 偏航角(度)。
pub yaw: f32,
/// 单调时钟时间戳(纳秒)。
pub timestamp_ns: u64,
}
impl ImuData {
/// 所有字段是否有限(NaN / inf 不得进入控制律)。
#[must_use]
pub fn is_finite(&self) -> bool {
self.accel.iter().chain(&self.gyro).chain(&self.mag).all(|v| v.is_finite())
&& self.roll.is_finite()
&& self.pitch.is_finite()
&& self.yaw.is_finite()
}
}
/// IMU 读取错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImuError {
/// 链路关闭。
Closed,
/// 帧 ID / 长度不符(多半是总线接错或协议模式不对)。
BadFrame,
/// **帧载荷布局尚未确认**(ARCHITECTURE.md 决策点 D1)。
///
/// 见 `delta10-protocol`:DETA10 手册链接已失效,载荷偏移在
/// 拿到数据手册前不得臆造,因此解码器会显式返回本错误。
LayoutUnconfirmed,
/// 校验和 / 校验失败。
Checksum,
/// 底层 IO 失败。
Io,
}
impl core::fmt::Display for ImuError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Closed => write!(f, "IMU 链路已关闭"),
Self::BadFrame => write!(f, "IMU 帧 ID 或长度不符"),
Self::LayoutUnconfirmed => {
write!(f, "IMU 载荷布局未确认(ARCHITECTURE.md 决策点 D1:需 DETA10 数据手册)")
}
Self::Checksum => write!(f, "IMU 校验失败"),
Self::Io => write!(f, "IMU 底层 IO 失败"),
}
}
}
impl core::error::Error for ImuError {}
/// 硬件无关的 IMU 边界。
pub trait Imu {
/// 底层错误类型。
type Error: core::error::Error + Send + Sync + 'static;
/// 读取一帧姿态数据(阻塞直到有帧或链路关闭)。
///
/// # Errors
///
/// 链路关闭、帧不合法、载荷布局未确认时返回错误。
fn read(&mut self) -> impl core::future::Future<Output = Result<ImuData, Self::Error>>;
/// 非阻塞读取:本轮没有新帧时返回 `Ok(None)`。
///
/// # Errors
///
/// 仅在链路确定损坏时返回错误。
fn try_read(&mut self) -> Result<Option<ImuData>, Self::Error> {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_data_is_zero_and_finite() {
let data = ImuData::default();
assert!((data.yaw).abs() < 1e-6);
assert!(data.is_finite());
}
#[test]
fn non_finite_attitude_is_detected() {
let data = ImuData { pitch: f32::NAN, ..ImuData::default() };
assert!(!data.is_finite());
let data = ImuData { gyro: [0.0, f32::INFINITY, 0.0], ..ImuData::default() };
assert!(!data.is_finite());
}
#[test]
fn layout_unconfirmed_error_names_the_decision_point() {
let text = ImuError::LayoutUnconfirmed.to_string();
assert!(text.contains("D1"), "{text}");
}
}
+2 -1
View File
@@ -1,16 +1,17 @@
[package] [package]
name = "mecanum" name = "mecanum"
description = "Mecanum chassis kinematics"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
description = "Mecanum wheel chassis forward/inverse kinematics"
[lints] [lints]
workspace = true workspace = true
[dependencies] [dependencies]
[dev-dependencies] [dev-dependencies]
proptest = { workspace = true } proptest = { workspace = true }
approx = { workspace = true } approx = { workspace = true }
@@ -1,7 +0,0 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 698ba63bf948a3702145ece17ed0f014133de0d0b3797fd8bdda514c5d2480a7 # shrinks to vx = 0.0, vy = 0.0, omega = -2.3901656
+2
View File
@@ -1,3 +1,5 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! 麦克纳姆轮底盘正 / 逆运动学。 //! 麦克纳姆轮底盘正 / 逆运动学。
//! //!
//! # 约定 //! # 约定
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "motor-control"
description = "Motor velocity, position, cascade, and feedforward control"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
pid = { workspace = true }
motor = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
+798
View File
@@ -0,0 +1,798 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub mod feedforward {
//! 前馈补偿(ARCHITECTURE.md §5.5)。
//!
//! 速度环的前馈是**比例前馈**:稳态下所需控制量与目标转速近似成正比,
//! 因此 `u_ff = k_ff · setpoint` 可以直接把积分项从"慢慢顶"变成"一开始就到位"。
//!
//! # 为什么前馈在空载台架上必须为 0
//!
//! `k_ff ≈ 1/K`,其中 `K` 是被控对象的稳态增益(GM3508 空载实测
//! `K ≈ 0.57 rpm/raw` → `k_ff ≈ 1.77`)。空载时维持转速只需要克服摩擦的极小
//! 电流,任何接近 1.77 的前馈都会把轮子直接推到最高转速(见 `docs/chassis.md` §7.2)。
/// 比例前馈:`输出 = k_ff · setpoint`。
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct ProportionalFeedforward {
/// 前馈系数(控制量 / 目标量)。
pub k_ff: f32,
}
impl ProportionalFeedforward {
/// 构造。
#[must_use]
pub const fn new(k_ff: f32) -> Self {
Self { k_ff }
}
/// 由被控对象稳态增益换算:`k_ff = 1 / K`。
///
/// `K` 非有限或 ≤ 0 时返回 0(宁可不前馈,也不要一个发散的系数)。
#[must_use]
pub fn from_plant_gain(plant_gain: f32) -> Self {
if plant_gain.is_finite() && plant_gain > 0.0 {
Self { k_ff: 1.0 / plant_gain }
} else {
Self { k_ff: 0.0 }
}
}
/// 计算前馈量。
#[must_use]
pub fn compute(&self, setpoint: f32) -> f32 {
if setpoint.is_finite() { self.k_ff * setpoint } else { 0.0 }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inverse_plant_gain_matches_the_documented_bench_feedforward() {
// GM3508 空载 K ≈ 0.5648 rpm/raw → k_ff ≈ 1.77 raw/rpm
let feedforward = ProportionalFeedforward::from_plant_gain(9254.0 / 16384.0);
assert!((feedforward.k_ff - 1.77).abs() < 0.02, "{}", feedforward.k_ff);
}
#[test]
fn degenerate_plant_gain_yields_no_feedforward() {
assert!((ProportionalFeedforward::from_plant_gain(0.0).k_ff).abs() < 1e-6);
assert!((ProportionalFeedforward::from_plant_gain(-1.0).k_ff).abs() < 1e-6);
assert!((ProportionalFeedforward::from_plant_gain(f32::NAN).k_ff).abs() < 1e-6);
}
#[test]
fn compute_is_linear_and_safe() {
let feedforward = ProportionalFeedforward::new(2.0);
assert!((feedforward.compute(3.0) - 6.0).abs() < 1e-6);
assert!((feedforward.compute(f32::INFINITY)).abs() < 1e-6);
assert!((ProportionalFeedforward::default().compute(100.0)).abs() < 1e-6);
}
}
}
pub use feedforward::*;
pub mod plant {
//! 被控对象抽象:让**仿真与实机共用同一套 PID**(ARCHITECTURE.md §5.5)。
//!
//! 这是整定能否迁移到实机的关键 seam:整定器只认识 [`ControlPlant`],
//! 因此"在仿真里搜出来的增益"与"在台架上验出来的增益"评估的是同一条接口。
//!
//! 实现方:
//! - 仿真:`sim-harness::SimPlant`(浮点积分,无 IO,可并行上千次);
//! - 实机/台架:`bench` 的台架采样(每次试验 = 真机跑一条场景)。
/// 单输入单输出的被控对象。
///
/// 约定:`step` 推进 `dt` 秒并返回**本次的测量输出**;输入是控制量
/// (`motor-control` 里即 effort raw)。实现必须保证 `dt <= 0` 时不推进状态。
pub trait ControlPlant {
/// 推进一拍的误差类型(平台相关实现可以自定义)。
type Error;
/// 推进一步,返回测量输出。
///
/// # Errors
///
/// 数值发散或输入非法时返回错误,由调用方折算成硬约束罚值。
fn step(&mut self, input: f32, dt: f32) -> Result<f32, Self::Error>;
/// 复位到初始状态(两次试验之间必须调用,否则结果依赖历史)。
fn reset(&mut self);
/// 人类可读描述(写进报告,便于复现实验条件)。
fn describe(&self) -> String;
}
/// 无错误的被控对象(纯代数模型 / 已在内部处理发散的实现)用它的适配器。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Infallible;
impl core::fmt::Display for Infallible {
fn fmt(&self, _f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
Ok(())
}
}
impl core::error::Error for Infallible {}
/// 一阶惯性环节:`y' = (k·u − y) / tau`,显式欧拉。
///
/// 这是最小可用的 [`ControlPlant`] 实现,用来给控制律 / 整定器做单元测试;
/// 带摩擦与延迟的完整电机模型在 `sim-harness`。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FirstOrderPlant {
/// 稳态增益。
pub gain: f32,
/// 时间常数(秒)。
pub tau_s: f32,
/// 当前输出。
pub output: f32,
}
impl FirstOrderPlant {
/// 构造(`tau_s` 必须为正,否则退化为直通)。
#[must_use]
pub fn new(gain: f32, tau_s: f32) -> Self {
Self { gain, tau_s: if tau_s > 0.0 { tau_s } else { f32::EPSILON }, output: 0.0 }
}
}
impl ControlPlant for FirstOrderPlant {
type Error = Infallible;
fn step(&mut self, input: f32, dt: f32) -> Result<f32, Self::Error> {
if dt > 0.0 && input.is_finite() {
let alpha = (dt / self.tau_s).clamp(0.0, 1.0);
let target = self.gain * input;
self.output += alpha * (target - self.output);
}
Ok(self.output)
}
fn reset(&mut self) {
self.output = 0.0;
}
fn describe(&self) -> String {
format!("first-order(gain={}, tau={}s)", self.gain, self.tau_s)
}
}
/// 标准二阶系统:`G(s) = gain·wn² / (s² + 2ζ·wn·s + wn²)`,显式欧拉。
///
/// 用作"整定算法是否真的收敛"的试金石——ARCHITECTURE.md §5.5 的验收标准就要求
/// 继电整定在标准二阶系统上收敛。
///
/// # 相频特性(做继电实验时必须知道)
///
/// `∠G(jω) = −atan2(2ζ·wn·ω, wn² − ω²)`:在 `ω = wn` 处只有 **−90°**,
/// 随 `ω → ∞` **渐近**趋于 −180°,**不存在有限的 −180° 穿越点**。
/// 因此纯二阶对象在继电反馈下的极限环由**滞环宽度**决定(幅值 ≈ 滞环量级、
/// 周期远小于 `2π/wn`),而不是由 `Tu = 2π/wn` 决定。
/// 真实电机带延迟(`sim-harness` 的模型有 2 拍命令延迟 + 摩擦),相频会真正穿过
/// −180°,那时的 `Tu` 才是有物理意义的临界周期。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SecondOrderPlant {
/// 自然频率(rad/s)。
pub wn: f32,
/// 阻尼比。
pub zeta: f32,
/// 直流增益。
pub gain: f32,
/// 位置状态。
pub position: f32,
/// 速度状态。
pub velocity: f32,
}
impl SecondOrderPlant {
/// 构造(`wn > 0`;`zeta` 取绝对值)。
#[must_use]
pub fn new(wn: f32, zeta: f32, gain: f32) -> Self {
Self {
wn: if wn > 0.0 { wn } else { f32::EPSILON },
zeta: zeta.abs(),
gain,
position: 0.0,
velocity: 0.0,
}
}
}
impl ControlPlant for SecondOrderPlant {
type Error = Infallible;
fn step(&mut self, input: f32, dt: f32) -> Result<f32, Self::Error> {
if dt > 0.0 && input.is_finite() {
let accel = self.gain * self.wn * self.wn * input
- 2.0 * self.zeta * self.wn * self.velocity
- self.wn * self.wn * self.position;
self.velocity += accel * dt;
self.position += self.velocity * dt;
}
Ok(self.position)
}
fn reset(&mut self) {
self.position = 0.0;
self.velocity = 0.0;
}
fn describe(&self) -> String {
format!("second-order(wn={} rad/s, zeta={}, gain={})", self.wn, self.zeta, self.gain)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn second_order_settles_at_dc_gain() {
let mut plant = SecondOrderPlant::new(20.0, 0.7, 2.0);
for _ in 0..20_000 {
plant.step(1.0, 0.0001).unwrap();
}
assert!((plant.position - 2.0).abs() < 5e-2, "{}", plant.position);
plant.reset();
assert!((plant.position).abs() < 1e-6);
assert!(plant.describe().contains("second-order"));
}
#[test]
fn second_order_ignores_non_positive_dt() {
let mut plant = SecondOrderPlant::new(20.0, 0.5, 1.0);
assert!((plant.step(1.0, 0.0).unwrap()).abs() < 1e-6);
assert!((plant.velocity).abs() < 1e-6);
}
#[test]
fn first_order_converges_to_gain_times_input() {
let mut plant = FirstOrderPlant::new(2.0, 0.1);
for _ in 0..2000 {
plant.step(1.0, 0.001).unwrap();
}
assert!((plant.output - 2.0).abs() < 1e-3, "{}", plant.output);
}
#[test]
fn non_positive_dt_does_not_advance() {
let mut plant = FirstOrderPlant::new(1.0, 0.1);
assert!((plant.step(5.0, 0.0).unwrap()).abs() < 1e-6);
assert!((plant.step(5.0, -1.0).unwrap()).abs() < 1e-6);
assert!((plant.step(f32::NAN, 0.001).unwrap()).abs() < 1e-6);
}
#[test]
fn reset_clears_history() {
let mut plant = FirstOrderPlant::new(1.0, 0.05);
for _ in 0..100 {
plant.step(1.0, 0.001).unwrap();
}
assert!(plant.output > 0.0);
plant.reset();
assert!((plant.output).abs() < 1e-6);
assert!(plant.describe().contains("first-order"));
}
}
}
pub use plant::*;
pub mod position {
//! 位置环控制器:单环(直出)或级联(位置→限速→速度环)。
use crate::velocity::VelocityController;
use pid::Pid;
/// 位置环拓扑。
#[derive(Debug, Clone)]
pub enum PositionLoop {
/// 单环:位置误差 → effort_raw(适合电压硬闭环 / 刚性云台)。
Direct(Pid),
/// 级联:位置 → 目标转速 → 速度环 → effort_raw。
Cascade {
/// 外环位置 PID(输出限幅 = 最大 rpm)。
pos: Pid,
/// 内环速度控制器(输出限幅 = 最大 effort)。
vel: VelocityController,
},
}
/// 位置环封装。输入为目标 / 实测多圈位置(圈),输出为 effort_raw。
#[derive(Debug, Clone)]
pub struct PositionController {
inner: PositionLoop,
}
impl PositionController {
/// 单环位置控制器。
#[must_use]
pub fn direct(pid: Pid) -> Self {
Self { inner: PositionLoop::Direct(pid) }
}
/// 级联位置控制器(限速)。
#[must_use]
pub fn cascade(pos: Pid, vel: VelocityController) -> Self {
Self { inner: PositionLoop::Cascade { pos, vel } }
}
/// 单步更新,返回 effort_raw(已含限幅)。
///
/// `measured_rpm` 仅级联内环使用;单环忽略该参数。
pub fn update(
&mut self,
target_rev: f32,
measured_rev: f32,
measured_rpm: f32,
dt: f32,
) -> f32 {
match &mut self.inner {
PositionLoop::Direct(pid) => pid.update(target_rev, measured_rev, dt),
PositionLoop::Cascade { pos, vel } => {
let vel_target = pos.update(target_rev, measured_rev, dt);
vel.update(vel_target, measured_rpm, dt)
}
}
}
/// 清空内部状态(切换模式 / 重新使能时调用)。
pub fn reset(&mut self) {
match &mut self.inner {
PositionLoop::Direct(pid) => pid.reset(),
PositionLoop::Cascade { pos, vel } => {
pos.reset();
vel.reset();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const DT: f32 = 0.001;
#[test]
fn direct_outputs_pid() {
let mut pc = PositionController::direct(Pid::new(2.0, 0.0, 0.0, -100.0, 100.0));
let out = pc.update(1.0, 0.5, 0.0, DT);
assert!((out - 1.0).abs() < 1e-4, "out = {out}");
}
#[test]
fn cascade_limits_velocity_then_effort() {
// 外环 kp=10 → 误差 1 圈输出 10 rpm(限幅 5 rpm);内环纯前馈 2 raw/rpm。
let pos = Pid::new(10.0, 0.0, 0.0, -5.0, 5.0);
let vel = VelocityController::new(Pid::new(0.0, 0.0, 0.0, -1000.0, 1000.0), 2.0);
let mut pc = PositionController::cascade(pos, vel);
let out = pc.update(1.0, 0.0, 0.0, DT);
// 外环饱和到 5 rpm,内环 ff=2 → 10 raw。
assert!((out - 10.0).abs() < 1e-3, "out = {out}");
}
#[test]
fn reset_clears_state() {
let mut pc = PositionController::direct(Pid::new(0.0, 10.0, 0.0, -1e6, 1e6));
pc.update(1.0, 0.0, 0.0, DT);
pc.reset();
let out = pc.update(1.0, 0.0, 0.0, DT);
// 重置后只剩本步积分:10 * 1 * 0.001。
assert!((out - 0.01).abs() < 1e-5, "out = {out}");
}
}
}
pub use position::*;
pub mod velocity {
//! 速度环控制器:PID + 速度前馈。
use pid::Pid;
/// 速度环增益组(可整定参数)。
///
/// 单位:`kp` 为 raw / rpm,`ki` 为 raw / (rpm·s),`kd` 为 raw / (rpm/s),
/// `feedforward` 为 raw / rpm。整套增益是**纯数据**,便于整定工具在候选之间
/// 传递、落盘与热更新,而无需重建整个速度环。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VelocityGains {
/// 比例增益(raw / rpm)。
pub kp: f32,
/// 积分增益(raw / (rpm·s))。
pub ki: f32,
/// 微分增益(raw / (rpm/s))。
pub kd: f32,
/// 速度前馈(raw / rpm)。
pub feedforward: f32,
}
impl VelocityGains {
/// 全零增益。
pub const ZERO: Self = Self { kp: 0.0, ki: 0.0, kd: 0.0, feedforward: 0.0 };
/// 由四个分量构造。
#[must_use]
pub const fn new(kp: f32, ki: f32, kd: f32, feedforward: f32) -> Self {
Self { kp, ki, kd, feedforward }
}
/// 所有增益是否有限(NaN / inf 不得进入 [`Pid::new`],它会 panic)。
#[must_use]
pub fn is_finite(&self) -> bool {
self.kp.is_finite()
&& self.ki.is_finite()
&& self.kd.is_finite()
&& self.feedforward.is_finite()
}
}
/// 速度环封装:在 [`Pid`] 之上叠加比例前馈。
///
/// 输入为目标 / 实测转速(rpm,物理方向),输出为开环控制量
/// effort_raw(协议 raw:ESC=电流,GM6020=电压)。
/// 前馈系数 `feedforward` 的单位是 **raw / rpm**——稳态下所需控制量
/// 与转速近似成正比(GM3508 电流典型值约 9 raw/rpm,需按实车整定)。
///
/// 由驱动层按控制节拍(如 C620 的 1kHz 发送周期)调用。
#[derive(Debug, Clone)]
pub struct VelocityController {
pid: Pid,
feedforward: f32,
}
impl VelocityController {
/// 创建速度环。`pid` 的输出限幅应设为该电机的 effort 限幅(raw)。
#[must_use]
pub fn new(pid: Pid, feedforward: f32) -> Self {
Self { pid, feedforward }
}
/// 由增益组与 effort 限幅直接创建(把 [`VelocityGains`] 展开成 [`Pid`])。
///
/// # Panics
///
/// `output_limit_raw` 为 0,或增益含 NaN / inf 时 panic(同 [`Pid::new`])。
#[must_use]
pub fn from_gains(output_limit_raw: i16, gains: VelocityGains) -> Self {
let limit = f32::from(output_limit_raw);
let pid = Pid::new(gains.kp, gains.ki, gains.kd, -limit, limit);
Self { pid, feedforward: gains.feedforward }
}
/// 当前增益组(含前馈),用于诊断与热更新前的读取。
#[must_use]
pub fn gains(&self) -> VelocityGains {
VelocityGains {
kp: self.pid.kp(),
ki: self.pid.ki(),
kd: self.pid.kd(),
feedforward: self.feedforward,
}
}
/// 单步更新,返回 effort_raw(已含 PID 输出限幅)。
pub fn update(&mut self, target_rpm: f32, measured_rpm: f32, dt: f32) -> f32 {
let pid_out = self.pid.update(target_rpm, measured_rpm, dt);
(pid_out + self.feedforward * target_rpm).clamp(
// 前馈可能把输出推出限幅之外,这里以 PID 限幅为准再夹一次。
self.pid.output_min(),
self.pid.output_max(),
)
}
/// 清空 PID 内部状态(切换模式 / 重新使能时调用)。
pub fn reset(&mut self) {
self.pid.reset();
}
/// 前馈系数(raw / rpm)。
#[must_use]
pub fn feedforward(&self) -> f32 {
self.feedforward
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pure_feedforward_tracks_target() {
let mut vc = VelocityController::new(Pid::new(0.0, 0.0, 0.0, -16384.0, 16384.0), 9.0);
assert!((vc.update(1000.0, 0.0, 0.001) - 9000.0).abs() < 1e-3);
// 前馈 -18000 被限幅到 -16384。
assert!((vc.update(-2000.0, 0.0, 0.001) + 16384.0).abs() < 1e-3);
}
#[test]
fn feedforward_plus_pid_respects_limit() {
let mut vc = VelocityController::new(Pid::new(50.0, 0.0, 0.0, -12000.0, 12000.0), 9.0);
// 前馈 9000 + P 项 50*100 = 14000 → 限幅到 12000。
let out = vc.update(1000.0, 0.0, 0.001);
assert!((out - 12000.0).abs() < 1e-3, "out = {out}");
}
#[test]
fn zero_error_yields_pure_feedforward() {
// 误差为 0 时 PID 贡献为 0,输出只剩前馈。
let mut vc = VelocityController::new(Pid::new(10.0, 5.0, 0.0, -1e6, 1e6), 9.0);
let out = vc.update(1000.0, 1000.0, 0.001);
assert!((out - 9000.0).abs() < 1e-4, "out = {out}");
}
#[test]
fn reset_clears_pid_state() {
let mut vc = VelocityController::new(Pid::new(10.0, 100.0, 0.0, -1e6, 1e6), 0.0);
for _ in 0..10 {
vc.update(100.0, 0.0, 0.001);
}
vc.reset();
// 重置后首步输出 = 纯 P + 本步积分(ki*e*dt = 10)。
let out = vc.update(100.0, 0.0, 0.001);
assert!((out - 1010.0).abs() < 1e-3, "out = {out}");
}
#[test]
fn from_gains_round_trips_through_gains() {
let gains = VelocityGains::new(8.0, 2.0, 0.5, 1.77);
let mut from_data = VelocityController::from_gains(12_000, gains);
let mut by_hand =
VelocityController::new(Pid::new(8.0, 2.0, 0.5, -12_000.0, 12_000.0), 1.77);
assert_eq!(from_data.gains(), gains);
// 两条构造路径必须逐位一致。
for step in 0..16 {
let target = 1000.0 + step as f32;
let measured = 900.0 + step as f32 * 3.0;
assert_eq!(
from_data.update(target, measured, 0.001).to_bits(),
by_hand.update(target, measured, 0.001).to_bits()
);
}
}
#[test]
fn from_gains_clamps_to_output_limit() {
let mut vc =
VelocityController::from_gains(1000, VelocityGains::new(100.0, 0.0, 0.0, 0.0));
assert!((vc.update(1e5, 0.0, 0.001) - 1000.0).abs() < 1e-3);
}
#[test]
fn gains_finiteness_is_checkable() {
assert!(VelocityGains::new(1.0, 2.0, 3.0, 4.0).is_finite());
assert!(!VelocityGains::new(f32::NAN, 2.0, 3.0, 4.0).is_finite());
assert!(!VelocityGains::new(1.0, f32::INFINITY, 3.0, 4.0).is_finite());
assert_eq!(VelocityGains::ZERO, VelocityGains::new(0.0, 0.0, 0.0, 0.0));
}
}
}
pub use velocity::*;
pub mod cascade {
//! 串级控制:位置环(外)+ 速度环(内)(ARCHITECTURE.md §5.5)。
//!
//! 外环输出的是**速度目标**而不是控制量,因此内环必须在每个节拍都跑一次——
//! 这也是云台(位置)与底盘(速度)共用的结构:底盘只用内环,云台两环都用。
//!
//! ```text
//! 位置目标 ──►[ 外环 PID ]──► 速度目标 ──►[ 内环 PID ]──► effort
//! ▲ ▲
//! 位置反馈 速度反馈
//! ```
use pid::Pid;
/// 位置-速度串级控制器。
#[derive(Debug, Clone)]
pub struct CascadeController {
/// 外环:位置 → 速度目标。
outer: Pid,
/// 内环:速度 → 控制量。
inner: Pid,
}
impl CascadeController {
/// 由两个已配置好的 PID 组装。
#[must_use]
pub const fn new(outer: Pid, inner: Pid) -> Self {
Self { outer, inner }
}
/// 单步更新,返回内环输出(控制量)。
///
/// 非有限输入会被内环防御性忽略(见 [`Pid::update`]),因此本函数不会 panic。
pub fn compute(
&mut self,
position_setpoint: f32,
position_feedback: f32,
velocity_feedback: f32,
dt: f32,
) -> f32 {
let velocity_setpoint = self.outer.update(position_setpoint, position_feedback, dt);
self.inner.update(velocity_setpoint, velocity_feedback, dt)
}
/// 外环当前建议的速度目标(诊断 / 限幅用)。
#[must_use]
pub fn last_velocity_setpoint(&self) -> f32 {
self.outer.last_output()
}
/// 清空两环的积分与微分记忆。
pub fn reset(&mut self) {
self.outer.reset();
self.inner.reset();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plant::{ControlPlant, FirstOrderPlant};
#[test]
fn cascade_drives_a_first_order_plant_to_the_position_setpoint() {
// 内环(速度)与对象同量纲简化:直接把外环输出当对象输入,
// 用真实的一阶对象验证"两环串联能收敛"。
let mut cascade = CascadeController::new(
Pid::new(4.0, 8.0, 0.0, -100.0, 100.0),
Pid::new(6.0, 20.0, 0.0, -100.0, 100.0),
);
let mut plant = FirstOrderPlant::new(1.0, 0.05);
let dt = 0.001;
let mut position = 0.0f32;
for _ in 0..4000 {
let effort = cascade.compute(1.0, position, 0.0, dt);
let velocity = plant.step(effort, dt).unwrap();
position += velocity * dt;
}
assert!((position - 1.0).abs() < 0.05, "position = {position}");
assert!(plant.describe().contains("first-order"));
}
#[test]
fn reset_clears_integral_windup() {
let mut cascade = CascadeController::new(
Pid::new(1.0, 1.0, 0.0, -10.0, 10.0),
Pid::new(1.0, 1.0, 0.0, -10.0, 10.0),
);
for _ in 0..100 {
cascade.compute(100.0, 0.0, 0.0, 0.001);
}
assert!(cascade.last_velocity_setpoint().abs() > 0.0);
cascade.reset();
assert!((cascade.last_velocity_setpoint()).abs() < 1e-6);
}
}
}
pub use cascade::*;
/// Per-motor controller state used by [`MotorPolicy`].
struct PolicyLoop {
current_limit: i16,
gains: VelocityGains,
velocity: VelocityController,
position: PositionController,
last_mode: motor::CommandMode,
}
impl PolicyLoop {
fn new(limit: i16, gains: VelocityGains) -> Self {
let bound = f32::from(limit);
Self {
current_limit: limit,
gains,
velocity: VelocityController::from_gains(limit, gains),
position: PositionController::direct(pid::Pid::new(
gains.kp, gains.ki, gains.kd, -bound, bound,
)),
last_mode: motor::CommandMode::Disabled,
}
}
fn reset(&mut self) {
self.velocity.reset();
self.position.reset();
self.last_mode = motor::CommandMode::Disabled;
}
fn effort(&mut self, sample: motor::PolicySample, dt: f32) -> i16 {
if !sample.online {
self.reset();
return 0;
}
if self.last_mode != sample.command.mode() {
self.reset();
self.last_mode = sample.command.mode();
}
match sample.command {
motor::Command::Disabled => {
self.reset();
0
}
motor::Command::EffortRaw(raw) => raw,
motor::Command::Velocity(target) => {
self.velocity.update(target, sample.state.rotor_speed_rpm, dt) as i16
}
motor::Command::Position(target) => self.position.update(
target,
sample.state.rotor_revolutions,
sample.state.rotor_speed_rpm,
dt,
) as i16,
}
}
}
/// Concrete velocity/position policy for up to eight ESCs.
pub struct MotorPolicy {
loops: [Option<PolicyLoop>; 8],
}
impl MotorPolicy {
/// Create an empty policy.
#[must_use]
pub fn new() -> Self {
Self { loops: std::array::from_fn(|_| None) }
}
/// Configure one ESC controller.
pub fn configure(&mut self, esc_id: u8, current_limit: i16, gains: VelocityGains) {
if let Some(slot) = self.loops.get_mut(usize::from(esc_id.saturating_sub(1))) {
*slot = Some(PolicyLoop::new(current_limit, gains));
}
}
/// Read one controller's current gains.
#[must_use]
pub fn velocity_gains(&self, esc_id: u8) -> Option<VelocityGains> {
self.loops
.get(usize::from(esc_id.saturating_sub(1)))
.and_then(Option::as_ref)
.map(|loop_| loop_.gains)
}
/// Replace one controller's gains without changing its output limit.
pub fn set_velocity_gains(&mut self, esc_id: u8, gains: VelocityGains) {
if let Some(Some(loop_)) = self.loops.get_mut(usize::from(esc_id.saturating_sub(1))) {
let limit = loop_.current_limit;
*loop_ = PolicyLoop::new(limit, gains);
}
}
}
impl Default for MotorPolicy {
fn default() -> Self {
Self::new()
}
}
impl motor::EffortPolicy for MotorPolicy {
fn step(&mut self, input: &motor::PolicyInput) -> [i16; 8] {
std::array::from_fn(|index| {
let sample = input.samples[index];
match self.loops[index].as_mut() {
Some(loop_) if sample.id == u8::try_from(index + 1).unwrap_or(0) => {
loop_.effort(sample, input.dt)
}
_ => 0,
}
})
}
fn reset(&mut self, esc_id: u8) {
if let Some(Some(loop_)) = self.loops.get_mut(usize::from(esc_id.saturating_sub(1))) {
loop_.reset();
}
}
}
-41
View File
@@ -1,41 +0,0 @@
//! 驱动层命令寄存器(最后写入胜出)。
/// 发给驱动层的电机命令。
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Command {
/// 停用:目标清零,下发 0 输出。
Disabled,
/// 直接下发开环 raw(协议量程,物理方向),绕过闭环。
/// ESC = 电流 raw;GM6020 = 电压 raw。
EffortRaw(i16),
/// 速度环跟踪目标转子转速(rpm,物理方向)。
Velocity(f32),
/// 位置环跟踪目标转子多圈位置(圈,物理方向)。
Position(f32),
}
/// 命令模式标签(不含设定值)——用于切换时复位控制器。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandMode {
/// 停用。
Disabled,
/// 开环 raw。
EffortRaw,
/// 速度环。
Velocity,
/// 位置环。
Position,
}
impl Command {
/// 模式标签。
#[must_use]
pub const fn mode(self) -> CommandMode {
match self {
Self::Disabled => CommandMode::Disabled,
Self::EffortRaw(_) => CommandMode::EffortRaw,
Self::Velocity(_) => CommandMode::Velocity,
Self::Position(_) => CommandMode::Position,
}
}
}
-182
View File
@@ -1,182 +0,0 @@
//! 输出轴单位适配器:减速比换算。
//!
//! 把「输出轴单位」的驱动接口适配到「转子单位」的底层电机。
//! `gear_ratio = 转子 : 输出轴`(GM3508 ≈ 19.203)。
use std::time::Duration;
use crate::{Disable, EffortDrive, MotorFeedback, MotorState, PositionDrive, VelocityDrive};
/// 输出轴包装:对外接口使用输出轴单位,对内换算到转子单位。
pub struct OutputShaft<T> {
inner: T,
gear_ratio: f32,
}
impl<T> OutputShaft<T> {
/// 包装底层电机。`gear_ratio` = 转子 : 输出轴(> 0)。
///
/// # Panics
///
/// `gear_ratio` 非有限或 ≤ 0 时 panic。
#[must_use]
pub fn new(inner: T, gear_ratio: f32) -> Self {
assert!(
gear_ratio.is_finite() && gear_ratio > 0.0,
"gear_ratio must be > 0, got {gear_ratio}"
);
Self { inner, gear_ratio }
}
/// 减速比。
#[must_use]
pub fn gear_ratio(&self) -> f32 {
self.gear_ratio
}
/// 底层电机引用。
#[must_use]
pub fn inner(&self) -> &T {
&self.inner
}
/// 转子量 → 输出轴量。
#[must_use]
pub fn to_output(&self, rotor: f32) -> f32 {
rotor / self.gear_ratio
}
/// 输出轴量 → 转子量。
#[must_use]
pub fn to_rotor(&self, output: f32) -> f32 {
output * self.gear_ratio
}
/// 由反馈快照推算输出轴转速(rpm)。
#[must_use]
pub fn output_speed_rpm(&self, state: &MotorState) -> f32 {
state.rotor_speed_rpm / self.gear_ratio
}
}
impl<T: MotorFeedback> MotorFeedback for OutputShaft<T> {
fn id(&self) -> u8 {
self.inner.id()
}
fn state(&self) -> MotorState {
// 只透传转子量;不伪造输出轴字段。
self.inner.state()
}
fn feedback_age(&self) -> Option<Duration> {
self.inner.feedback_age()
}
}
impl<T: VelocityDrive> VelocityDrive for OutputShaft<T> {
/// `output_rpm` 为**输出轴**转速。
fn set_velocity(&self, output_rpm: f32) {
self.inner.set_velocity(self.to_rotor(output_rpm));
}
}
impl<T: EffortDrive> EffortDrive for OutputShaft<T> {
fn set_effort_raw(&self, raw: i16) {
self.inner.set_effort_raw(raw);
}
}
impl<T: PositionDrive> PositionDrive for OutputShaft<T> {
/// `output_rev` 为**输出轴**圈数。
fn set_position(&self, output_rev: f32) {
self.inner.set_position(self.to_rotor(output_rev));
}
}
impl<T: Disable> Disable for OutputShaft<T> {
fn disable(&self) {
self.inner.disable();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Command;
struct Stub {
cmd: std::sync::Mutex<Command>,
}
impl Stub {
fn new() -> Self {
Self { cmd: std::sync::Mutex::new(Command::Disabled) }
}
}
impl MotorFeedback for Stub {
fn id(&self) -> u8 {
1
}
fn state(&self) -> MotorState {
MotorState::default()
}
fn feedback_age(&self) -> Option<Duration> {
Some(Duration::ZERO)
}
}
impl VelocityDrive for Stub {
fn set_velocity(&self, rotor_rpm: f32) {
*self.cmd.lock().unwrap() = Command::Velocity(rotor_rpm);
}
}
impl EffortDrive for Stub {
fn set_effort_raw(&self, raw: i16) {
*self.cmd.lock().unwrap() = Command::EffortRaw(raw);
}
}
impl PositionDrive for Stub {
fn set_position(&self, rotor_rev: f32) {
*self.cmd.lock().unwrap() = Command::Position(rotor_rev);
}
}
impl Disable for Stub {
fn disable(&self) {
*self.cmd.lock().unwrap() = Command::Disabled;
}
}
#[test]
fn velocity_scales_by_gear() {
let shaft = OutputShaft::new(Stub::new(), 19.203);
shaft.set_velocity(100.0);
match *shaft.inner().cmd.lock().unwrap() {
Command::Velocity(rpm) => assert!((rpm - 1920.3).abs() < 0.1, "{rpm}"),
ref c => panic!("unexpected {c:?}"),
}
}
#[test]
fn position_scales_by_gear() {
let shaft = OutputShaft::new(Stub::new(), 36.0);
shaft.set_position(2.0);
match *shaft.inner().cmd.lock().unwrap() {
Command::Position(rev) => assert!((rev - 72.0).abs() < 1e-3, "{rev}"),
ref c => panic!("unexpected {c:?}"),
}
}
#[test]
fn effort_passes_through() {
let shaft = OutputShaft::new(Stub::new(), 10.0);
shaft.set_effort_raw(-1234);
assert_eq!(*shaft.inner().cmd.lock().unwrap(), Command::EffortRaw(-1234));
}
#[test]
#[should_panic = "gear_ratio"]
fn rejects_nonpositive_gear() {
let _ = OutputShaft::new(Stub::new(), 0.0);
}
}
-21
View File
@@ -1,21 +0,0 @@
//! 航向观测源(IMU / 磁力计 / 视觉等)——云台绝对角融合 seam。
//!
//! 电机抽象只保证 [`crate::MotorState::rotor_revolutions`] 连续(上电相对)。
//! 场地绝对角由上层(未来 `gimbal` crate)融合 IMU 与电机多圈得到。
use std::future::Future;
/// 绝对航向观测源。
pub trait HeadingSource: Send {
/// 机体航向角(rad,连续;与 chassis ISO 约定一致:x 前、逆时针为正)。
fn heading_rad(&self) -> impl Future<Output = f32> + Send;
}
/// 云台目标角的参考系。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeadingFrame {
/// 电机转子多圈(上电相对)。
RotorMultiTurn,
/// 场地绝对角(经 [`HeadingSource`] 对齐到转子基准后再下发)。
AbsoluteField,
}
-93
View File
@@ -1,93 +0,0 @@
//! 协议无关的电机抽象层。
//!
//! 本 crate 不做任何 I/O:它定义电机能力 trait([`MotorFeedback`] /
//! [`EffortDrive`] / [`VelocityDrive`] / [`PositionDrive`] / [`Disable`])、
//! 公共数据类型([`MotorState`]、[`Command`])以及纯控制算法([`Pid`]、
//! [`VelocityController`]、[`PositionController`])。具体总线协议在其上实现。
//!
//! # 单一职责
//!
//! - [`traits`]:能力面(小而正交);
//! - [`state`] / [`command`]:反馈快照与命令寄存器;
//! - [`pid`] / [`velocity`] / [`position`]:纯控制律;
//! - [`gearbox`]:减速比单位适配(输出轴 ↔ 转子);
//! - [`heading`]:航向观测 seam(云台绝对角融合用);
//! - [`testing`]:测试替身。
//!
//! # 单位与方向约定
//!
//! - 所有呈现给上层的量都是**安装方向修正后**的物理量:`direction = -1`
//! 的电机由驱动层在读写两个方向上统一翻转符号。
//! - 转速一律为电机**转子**转速(rpm);输出轴换算见 [`OutputShaft`]。
//! - 开环控制量以协议原始值(raw)为主(C620 电流:±16384 ↔ ±20 A;
//! GM6020 电压:±30000 ≈ 满电压)。
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub mod command;
pub mod gearbox;
pub mod heading;
pub mod pid;
pub mod position;
pub mod state;
pub mod testing;
pub mod traits;
pub mod velocity;
pub use command::{Command, CommandMode};
pub use gearbox::OutputShaft;
pub use heading::{HeadingFrame, HeadingSource};
pub use pid::Pid;
pub use position::{PositionController, PositionLoop};
pub use state::MotorState;
pub use testing::{MockHeading, MockMotor};
pub use traits::{Disable, EffortDrive, Motor, MotorFeedback, PositionDrive, VelocityDrive};
pub use velocity::{VelocityController, VelocityGains};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_state_has_no_feedback() {
let state = MotorState::default();
assert!(!state.has_feedback());
assert_eq!(state.feedback_count, 0);
}
#[test]
fn feedback_gate_flips_on_first_frame() {
let state = MotorState { feedback_count: 1, ..MotorState::default() };
assert!(state.has_feedback());
}
#[test]
fn command_variants_roundtrip_by_value() {
let cmds = [
Command::Disabled,
Command::EffortRaw(-16384),
Command::Velocity(4820.5),
Command::Position(2.5),
];
assert_eq!(cmds[0], Command::Disabled);
assert_eq!(cmds[1], Command::EffortRaw(-16384));
assert_eq!(cmds[2], Command::Velocity(4820.5));
assert_eq!(cmds[3], Command::Position(2.5));
}
#[test]
fn command_mode_labels() {
assert_eq!(Command::Disabled.mode(), CommandMode::Disabled);
assert_eq!(Command::EffortRaw(0).mode(), CommandMode::EffortRaw);
assert_eq!(Command::Velocity(0.0).mode(), CommandMode::Velocity);
assert_eq!(Command::Position(0.0).mode(), CommandMode::Position);
}
#[test]
fn mock_motor_is_full_motor() {
fn takes_motor(m: &impl Motor) {
m.set_velocity(1.0);
m.disable();
}
takes_motor(&MockMotor::new(1));
}
}
-103
View File
@@ -1,103 +0,0 @@
//! 位置环控制器:单环(直出)或级联(位置→限速→速度环)。
use crate::pid::Pid;
use crate::velocity::VelocityController;
/// 位置环拓扑。
#[derive(Debug, Clone)]
pub enum PositionLoop {
/// 单环:位置误差 → effort_raw(适合电压硬闭环 / 刚性云台)。
Direct(Pid),
/// 级联:位置 → 目标转速 → 速度环 → effort_raw。
Cascade {
/// 外环位置 PID(输出限幅 = 最大 rpm)。
pos: Pid,
/// 内环速度控制器(输出限幅 = 最大 effort)。
vel: VelocityController,
},
}
/// 位置环封装。输入为目标 / 实测多圈位置(圈),输出为 effort_raw。
#[derive(Debug, Clone)]
pub struct PositionController {
inner: PositionLoop,
}
impl PositionController {
/// 单环位置控制器。
#[must_use]
pub fn direct(pid: Pid) -> Self {
Self { inner: PositionLoop::Direct(pid) }
}
/// 级联位置控制器(限速)。
#[must_use]
pub fn cascade(pos: Pid, vel: VelocityController) -> Self {
Self { inner: PositionLoop::Cascade { pos, vel } }
}
/// 单步更新,返回 effort_raw(已含限幅)。
///
/// `measured_rpm` 仅级联内环使用;单环忽略该参数。
pub fn update(
&mut self,
target_rev: f32,
measured_rev: f32,
measured_rpm: f32,
dt: f32,
) -> f32 {
match &mut self.inner {
PositionLoop::Direct(pid) => pid.update(target_rev, measured_rev, dt),
PositionLoop::Cascade { pos, vel } => {
let vel_target = pos.update(target_rev, measured_rev, dt);
vel.update(vel_target, measured_rpm, dt)
}
}
}
/// 清空内部状态(切换模式 / 重新使能时调用)。
pub fn reset(&mut self) {
match &mut self.inner {
PositionLoop::Direct(pid) => pid.reset(),
PositionLoop::Cascade { pos, vel } => {
pos.reset();
vel.reset();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const DT: f32 = 0.001;
#[test]
fn direct_outputs_pid() {
let mut pc = PositionController::direct(Pid::new(2.0, 0.0, 0.0, -100.0, 100.0));
let out = pc.update(1.0, 0.5, 0.0, DT);
assert!((out - 1.0).abs() < 1e-4, "out = {out}");
}
#[test]
fn cascade_limits_velocity_then_effort() {
// 外环 kp=10 → 误差 1 圈输出 10 rpm(限幅 5 rpm);内环纯前馈 2 raw/rpm。
let pos = Pid::new(10.0, 0.0, 0.0, -5.0, 5.0);
let vel = VelocityController::new(Pid::new(0.0, 0.0, 0.0, -1000.0, 1000.0), 2.0);
let mut pc = PositionController::cascade(pos, vel);
let out = pc.update(1.0, 0.0, 0.0, DT);
// 外环饱和到 5 rpm,内环 ff=2 → 10 raw。
assert!((out - 10.0).abs() < 1e-3, "out = {out}");
}
#[test]
fn reset_clears_state() {
let mut pc = PositionController::direct(Pid::new(0.0, 10.0, 0.0, -1e6, 1e6));
pc.update(1.0, 0.0, 0.0, DT);
pc.reset();
let out = pc.update(1.0, 0.0, 0.0, DT);
// 重置后只剩本步积分:10 * 1 * 0.001。
assert!((out - 0.01).abs() < 1e-5, "out = {out}");
}
}
-29
View File
@@ -1,29 +0,0 @@
//! 电机反馈快照(协议无关,方向修正后的物理量)。
//!
//! 只含**转子轴**物理量——减速比 / 输出轴换算由 [`crate::gearbox::OutputShaft`]
//! 或上层完成,不在此泄漏底盘语义。
/// 电机反馈快照。
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct MotorState {
/// 最近一帧反馈的转子机械原始角(编码器计数,量程由具体协议决定)。
pub rotor_angle_raw: u16,
/// 连续累计转子角(多圈回绕处理,单位:圈;随时间单调于转动方向)。
pub rotor_revolutions: f32,
/// 转子转速(rpm,物理方向)。
pub rotor_speed_rpm: f32,
/// 开环控制量反馈 raw(ESC=转矩电流,GM6020=电流相关量;物理方向)。
pub effort_raw: i16,
/// 电机温度(℃)。
pub temperature_c: i8,
/// 已收到的反馈帧总数;`0` 表示尚未收到任何反馈(上电门控用)。
pub feedback_count: u64,
}
impl MotorState {
/// 是否已收到过反馈(上电后首帧门控)。
#[must_use]
pub fn has_feedback(&self) -> bool {
self.feedback_count > 0
}
}
-145
View File
@@ -1,145 +0,0 @@
//! 测试替身:内存电机与航向源。
use std::future::Future;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use crate::{
Command, Disable, EffortDrive, HeadingSource, MotorFeedback, MotorState, PositionDrive,
VelocityDrive,
};
/// 锁辅助:中毒锁直接恢复内部数据。
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// 内存电机:记录命令、可注入状态。供上层单测,无需总线。
///
/// 句柄可克隆(内部 `Arc` 共享),便于一边注入/断言一边把所有权交给控制器。
#[derive(Clone)]
pub struct MockMotor {
id: u8,
published: Arc<Mutex<(MotorState, Command)>>,
last_feedback: Arc<Mutex<Option<Instant>>>,
}
impl MockMotor {
/// 创建指定 ID 的空电机。
#[must_use]
pub fn new(id: u8) -> Self {
Self {
id,
published: Arc::new(Mutex::new((MotorState::default(), Command::Disabled))),
last_feedback: Arc::new(Mutex::new(None)),
}
}
/// 注入反馈快照(记为「刚刚收到」)。
pub fn inject_state(&self, state: MotorState) {
self.inject_state_with_age(state, Duration::ZERO);
}
/// 注入反馈快照并指定其“已过去”的时长(用于在线/超时测试)。
pub fn inject_state_with_age(&self, state: MotorState, age: Duration) {
lock(&self.published).0 = state;
let at = Instant::now().checked_sub(age);
*lock(&self.last_feedback) = Some(at.unwrap_or_else(Instant::now));
}
/// 最近一次命令。
#[must_use]
pub fn last_command(&self) -> Command {
lock(&self.published).1
}
}
impl MotorFeedback for MockMotor {
fn id(&self) -> u8 {
self.id
}
fn state(&self) -> MotorState {
lock(&self.published).0
}
fn feedback_age(&self) -> Option<Duration> {
let at = *lock(&self.last_feedback);
Some(at?.elapsed())
}
}
impl VelocityDrive for MockMotor {
fn set_velocity(&self, rotor_rpm: f32) {
lock(&self.published).1 = Command::Velocity(rotor_rpm);
}
}
impl EffortDrive for MockMotor {
fn set_effort_raw(&self, raw: i16) {
lock(&self.published).1 = Command::EffortRaw(raw);
}
}
impl PositionDrive for MockMotor {
fn set_position(&self, rotor_rev: f32) {
lock(&self.published).1 = Command::Position(rotor_rev);
}
}
impl Disable for MockMotor {
fn disable(&self) {
lock(&self.published).1 = Command::Disabled;
}
}
/// 恒定航向测试源。
pub struct MockHeading {
rad: f32,
}
impl MockHeading {
/// 创建固定航向源。
#[must_use]
pub const fn new(rad: f32) -> Self {
Self { rad }
}
}
impl HeadingSource for MockHeading {
fn heading_rad(&self) -> impl Future<Output = f32> + Send {
std::future::ready(self.rad)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mock_motor_records_commands() {
let m = MockMotor::new(3);
m.set_velocity(1000.0);
assert_eq!(m.last_command(), Command::Velocity(1000.0));
m.disable();
assert_eq!(m.last_command(), Command::Disabled);
}
#[test]
fn mock_motor_injects_state() {
let m = MockMotor::new(1);
assert!(!m.state().has_feedback());
assert!(m.feedback_age().is_none());
m.inject_state(MotorState { feedback_count: 5, ..MotorState::default() });
assert!(m.state().has_feedback());
assert!(m.feedback_age().is_some_and(|age| age < Duration::from_millis(50)));
}
#[test]
fn mock_motor_feedback_age_can_be_stale() {
let m = MockMotor::new(1);
m.inject_state_with_age(
MotorState { feedback_count: 1, ..MotorState::default() },
Duration::from_millis(250),
);
assert!(m.feedback_age().is_some_and(|age| age >= Duration::from_millis(200)));
}
}
-55
View File
@@ -1,55 +0,0 @@
//! 能力面 trait:小而正交,按硬件事实拆分。
//!
//! 消费者只声明自己用到的能力(静态分发,无 `dyn`):
//! - 底盘轮 → [`VelocityDrive`] + [`MotorFeedback`]
//! - 云台 → [`PositionDrive`] + [`EffortDrive`](+ 上层 [`crate::HeadingSource`])
//! - 拨弹 → [`VelocityDrive`] + [`PositionDrive`]
//! - 台架 → [`VelocityDrive`] + [`MotorFeedback`] + [`Disable`]
use std::time::Duration;
use crate::MotorState;
/// 读反馈。所有可观察设备的最小面。
pub trait MotorFeedback: Send + Sync {
/// 控制器内的电机 ID(对 DJI 电调即电调 ID)。
fn id(&self) -> u8;
/// 最新反馈快照。
fn state(&self) -> MotorState;
/// 距最近一帧反馈的时长;`None` 表示从未收到。
///
/// 用于在线判定(反馈新鲜度),与 [`MotorState::has_feedback`]
/// (历史上是否收到过)区分:上电门控之后运行中掉线要看本方法。
fn feedback_age(&self) -> Option<Duration>;
}
/// 开环控制量(协议 raw:ESC=电流,GM6020=电压)。
pub trait EffortDrive: Send + Sync {
/// 直接下发开环 raw(协议量程,物理方向)。
fn set_effort_raw(&self, raw: i16);
}
/// 速度目标(转子 rpm,物理方向);由驱动层速度环跟踪。
pub trait VelocityDrive: Send + Sync {
/// 设定目标转子转速。
fn set_velocity(&self, rotor_rpm: f32);
}
/// 位置目标(转子多圈,圈;物理方向)。
pub trait PositionDrive: Send + Sync {
/// 设定目标转子多圈位置。
fn set_position(&self, rotor_rev: f32);
}
/// 停用(零输出)。
pub trait Disable: Send + Sync {
/// 停用电机。
fn disable(&self);
}
/// 便利别名(blanket impl,零成本)。实现方**不必**实现它。
pub trait Motor: MotorFeedback + VelocityDrive + EffortDrive + Disable {}
impl<T> Motor for T where T: MotorFeedback + VelocityDrive + EffortDrive + Disable {}
-180
View File
@@ -1,180 +0,0 @@
//! 速度环控制器:PID + 速度前馈。
use crate::pid::Pid;
/// 速度环增益组(可整定参数)。
///
/// 单位:`kp` 为 raw / rpm,`ki` 为 raw / (rpm·s),`kd` 为 raw / (rpm/s),
/// `feedforward` 为 raw / rpm。整套增益是**纯数据**,便于整定工具在候选之间
/// 传递、落盘与热更新,而无需重建整个速度环。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VelocityGains {
/// 比例增益(raw / rpm)。
pub kp: f32,
/// 积分增益(raw / (rpm·s))。
pub ki: f32,
/// 微分增益(raw / (rpm/s))。
pub kd: f32,
/// 速度前馈(raw / rpm)。
pub feedforward: f32,
}
impl VelocityGains {
/// 全零增益。
pub const ZERO: Self = Self { kp: 0.0, ki: 0.0, kd: 0.0, feedforward: 0.0 };
/// 由四个分量构造。
#[must_use]
pub const fn new(kp: f32, ki: f32, kd: f32, feedforward: f32) -> Self {
Self { kp, ki, kd, feedforward }
}
/// 所有增益是否有限(NaN / inf 不得进入 [`Pid::new`],它会 panic)。
#[must_use]
pub fn is_finite(&self) -> bool {
self.kp.is_finite()
&& self.ki.is_finite()
&& self.kd.is_finite()
&& self.feedforward.is_finite()
}
}
/// 速度环封装:在 [`Pid`] 之上叠加比例前馈。
///
/// 输入为目标 / 实测转速(rpm,物理方向),输出为开环控制量
/// effort_raw(协议 raw:ESC=电流,GM6020=电压)。
/// 前馈系数 `feedforward` 的单位是 **raw / rpm**——稳态下所需控制量
/// 与转速近似成正比(GM3508 电流典型值约 9 raw/rpm,需按实车整定)。
///
/// 由驱动层按控制节拍(如 C620 的 1kHz 发送周期)调用。
#[derive(Debug, Clone)]
pub struct VelocityController {
pid: Pid,
feedforward: f32,
}
impl VelocityController {
/// 创建速度环。`pid` 的输出限幅应设为该电机的 effort 限幅(raw)。
#[must_use]
pub fn new(pid: Pid, feedforward: f32) -> Self {
Self { pid, feedforward }
}
/// 由增益组与 effort 限幅直接创建(把 [`VelocityGains`] 展开成 [`Pid`])。
///
/// # Panics
///
/// `output_limit_raw` 为 0,或增益含 NaN / inf 时 panic(同 [`Pid::new`])。
#[must_use]
pub fn from_gains(output_limit_raw: i16, gains: VelocityGains) -> Self {
let limit = f32::from(output_limit_raw);
let pid = Pid::new(gains.kp, gains.ki, gains.kd, -limit, limit);
Self { pid, feedforward: gains.feedforward }
}
/// 当前增益组(含前馈),用于诊断与热更新前的读取。
#[must_use]
pub fn gains(&self) -> VelocityGains {
VelocityGains {
kp: self.pid.kp(),
ki: self.pid.ki(),
kd: self.pid.kd(),
feedforward: self.feedforward,
}
}
/// 单步更新,返回 effort_raw(已含 PID 输出限幅)。
pub fn update(&mut self, target_rpm: f32, measured_rpm: f32, dt: f32) -> f32 {
let pid_out = self.pid.update(target_rpm, measured_rpm, dt);
(pid_out + self.feedforward * target_rpm).clamp(
// 前馈可能把输出推出限幅之外,这里以 PID 限幅为准再夹一次。
self.pid.output_min(),
self.pid.output_max(),
)
}
/// 清空 PID 内部状态(切换模式 / 重新使能时调用)。
pub fn reset(&mut self) {
self.pid.reset();
}
/// 前馈系数(raw / rpm)。
#[must_use]
pub fn feedforward(&self) -> f32 {
self.feedforward
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pure_feedforward_tracks_target() {
let mut vc = VelocityController::new(Pid::new(0.0, 0.0, 0.0, -16384.0, 16384.0), 9.0);
assert!((vc.update(1000.0, 0.0, 0.001) - 9000.0).abs() < 1e-3);
// 前馈 -18000 被限幅到 -16384。
assert!((vc.update(-2000.0, 0.0, 0.001) + 16384.0).abs() < 1e-3);
}
#[test]
fn feedforward_plus_pid_respects_limit() {
let mut vc = VelocityController::new(Pid::new(50.0, 0.0, 0.0, -12000.0, 12000.0), 9.0);
// 前馈 9000 + P 项 50*100 = 14000 → 限幅到 12000。
let out = vc.update(1000.0, 0.0, 0.001);
assert!((out - 12000.0).abs() < 1e-3, "out = {out}");
}
#[test]
fn zero_error_yields_pure_feedforward() {
// 误差为 0 时 PID 贡献为 0,输出只剩前馈。
let mut vc = VelocityController::new(Pid::new(10.0, 5.0, 0.0, -1e6, 1e6), 9.0);
let out = vc.update(1000.0, 1000.0, 0.001);
assert!((out - 9000.0).abs() < 1e-4, "out = {out}");
}
#[test]
fn reset_clears_pid_state() {
let mut vc = VelocityController::new(Pid::new(10.0, 100.0, 0.0, -1e6, 1e6), 0.0);
for _ in 0..10 {
vc.update(100.0, 0.0, 0.001);
}
vc.reset();
// 重置后首步输出 = 纯 P + 本步积分(ki*e*dt = 10)。
let out = vc.update(100.0, 0.0, 0.001);
assert!((out - 1010.0).abs() < 1e-3, "out = {out}");
}
#[test]
fn from_gains_round_trips_through_gains() {
let gains = VelocityGains::new(8.0, 2.0, 0.5, 1.77);
let mut from_data = VelocityController::from_gains(12_000, gains);
let mut by_hand =
VelocityController::new(Pid::new(8.0, 2.0, 0.5, -12_000.0, 12_000.0), 1.77);
assert_eq!(from_data.gains(), gains);
// 两条构造路径必须逐位一致。
for step in 0..16 {
let target = 1000.0 + step as f32;
let measured = 900.0 + step as f32 * 3.0;
assert_eq!(
from_data.update(target, measured, 0.001).to_bits(),
by_hand.update(target, measured, 0.001).to_bits()
);
}
}
#[test]
fn from_gains_clamps_to_output_limit() {
let mut vc = VelocityController::from_gains(1000, VelocityGains::new(100.0, 0.0, 0.0, 0.0));
assert!((vc.update(1e5, 0.0, 0.001) - 1000.0).abs() < 1e-3);
}
#[test]
fn gains_finiteness_is_checkable() {
assert!(VelocityGains::new(1.0, 2.0, 3.0, 4.0).is_finite());
assert!(!VelocityGains::new(f32::NAN, 2.0, 3.0, 4.0).is_finite());
assert!(!VelocityGains::new(1.0, f32::INFINITY, 3.0, 4.0).is_finite());
assert_eq!(VelocityGains::ZERO, VelocityGains::new(0.0, 0.0, 0.0, 0.0));
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "motor-model"
description = "DC motor physical simulation model"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
motor-protocol = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
+426
View File
@@ -0,0 +1,426 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! DC motor physical models.
pub mod dc_motor {
//! 被控对象模型:一阶转速 + 摩擦 / 延迟 / 负载。
//!
//! 电调坐标系(正电流 → 正转速),逐拍离散(`dt` 秒):
//!
//! 1. **命令延迟**:`current_raw` 进 FIFO,`delay_ticks` 拍后才真正作用(模拟
//! 链路 + 采样延迟;`0` 表示立即作用)。
//! 2. **摩擦**:先由 `u` 与当前运动状态算出净转矩输入
//! (静摩擦死区 → 库仑摩擦 → 黏滞阻尼,见 [`PlantMotor::step`])。
//! 3. **一阶转速**:`rpm_target = clamp(net × gain, ±max_rpm) − load_rpm`,
//! 转速以时间常数 `tau` 一阶趋近(`alpha = 1 − exp(−dt/tau)`)。
//! 4. **编码器积分与量化**:机械角按转速积分并对 8192 取模;反馈帧是
//! `EscFeedback`,因此角度被截断为 `u16`、转速被截断为 `i16`——量化是
//! 协议类型自带的,不需要额外开关。
//!
//! [`PlantParams::gm3508`] 复现最初的**无摩擦一阶模型**(`chassisd --mock`
//! 与闭环测试依赖它,行为逐位不变);[`PlantParams::gm3508_bench`] 是带摩擦
//! 与延迟的**台架量级**预设,供自动整定使用。
use std::collections::VecDeque;
use motor_protocol::{ENCODER_TICKS, EscFeedback};
/// 转速绝对值低于该值(rpm)视为静止——静摩擦判别用。
const STICK_RPM_EPS: f32 = 1.0;
/// 被控对象参数(电调坐标系)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlantParams {
/// 空载增益(rpm / raw):`GM3508` 实测约 0.565(≈ 9254 / 16384)。
pub gain_rpm_per_raw: f32,
/// 机电时间常数(秒)。
pub tau_s: f32,
/// 转速上限(rpm)。
pub max_rpm: f32,
/// 静摩擦阈值(raw):静止且 |u| ≤ 该值时不产生任何运动(stick)。
pub static_raw: f32,
/// 库仑摩擦(raw):与运动方向相反的恒定阻力。
pub coulomb_raw: f32,
/// 黏滞阻尼(raw / rpm)。
pub viscous_raw_per_rpm: f32,
/// 命令传输延迟(拍,`dt` 的整数倍)。
pub delay_ticks: u32,
/// 恒定外载(折算成 rpm,直接被目标转速减去)。
pub load_rpm: f32,
}
impl PlantParams {
/// 无摩擦、无延迟的理想一阶模型(与历史 `PlantMotor::gm3508` 逐位一致)。
#[must_use]
pub const fn gm3508() -> Self {
Self {
gain_rpm_per_raw: 9254.0 / 16384.0,
tau_s: 0.12,
max_rpm: 9254.0,
static_raw: 0.0,
coulomb_raw: 0.0,
viscous_raw_per_rpm: 0.0,
delay_ticks: 0,
load_rpm: 0.0,
}
}
/// 台架量级预设:在 [`Self::gm3508`] 上叠加静摩擦 / 库仑摩擦 / 黏滞阻尼
/// 与 2 拍命令延迟。
///
/// 摩擦量级取自 `docs/architecture.md` 与台架记录的空载实测(起动需
/// 400~600 raw),**是量级估计而非辨识结果**;需要更贴合实车时可用
/// [`Self::with_friction`] / [`Self::with_delay_ticks`] 覆盖。
#[must_use]
pub const fn gm3508_bench() -> Self {
let mut params = Self::gm3508();
params.static_raw = 450.0;
params.coulomb_raw = 500.0;
params.viscous_raw_per_rpm = 0.05;
params.delay_ticks = 2;
params
}
/// 设置摩擦三件套(静摩擦阈值 / 库仑 / 黏滞)。
#[must_use]
pub const fn with_friction(
mut self,
static_raw: f32,
coulomb_raw: f32,
viscous_raw_per_rpm: f32,
) -> Self {
self.static_raw = static_raw;
self.coulomb_raw = coulomb_raw;
self.viscous_raw_per_rpm = viscous_raw_per_rpm;
self
}
/// 设置命令延迟(拍)。
#[must_use]
pub const fn with_delay_ticks(mut self, ticks: u32) -> Self {
self.delay_ticks = ticks;
self
}
/// 设置恒定外载(rpm)。
#[must_use]
pub const fn with_load_rpm(mut self, rpm: f32) -> Self {
self.load_rpm = rpm;
self
}
}
/// 单台电机被控对象。`Clone` 便于在同一组参数上重放多条场景。
#[derive(Debug, Clone)]
pub struct PlantMotor {
params: PlantParams,
speed_rpm: f32,
angle_raw: f32,
/// 命令延迟 FIFO(`delay_ticks == 0` 时恒为空)。
delay_line: VecDeque<f32>,
}
impl PlantMotor {
/// 用给定参数创建静止的电机。
#[must_use]
pub fn new(params: PlantParams) -> Self {
Self { params, speed_rpm: 0.0, angle_raw: 0.0, delay_line: VecDeque::new() }
}
/// 理想 `GM3508`(无摩擦、无延迟)——历史默认,`--mock` 与闭环测试沿用。
#[must_use]
pub fn gm3508() -> Self {
Self::new(PlantParams::gm3508())
}
/// 当前参数。
#[must_use]
pub const fn params(&self) -> &PlantParams {
&self.params
}
/// 当前转子转速(rpm,电调侧)。
#[must_use]
pub const fn speed_rpm(&self) -> f32 {
self.speed_rpm
}
/// 当前机械角(编码器计数,`0..8192`)。
#[must_use]
pub const fn angle_raw(&self) -> f32 {
self.angle_raw
}
/// 回到静止初态(保留参数)。
pub fn reset(&mut self) {
self.speed_rpm = 0.0;
self.angle_raw = 0.0;
self.delay_line.clear();
}
/// 推进一个时间步,返回本步反馈帧内容。
///
/// 摩擦模型(`net` 即折算到电流量纲的净驱动量):
///
/// - 运动时(`|rpm| > 1`):`net = u − sign(rpm)·coulomb − viscous·rpm`;
/// - 静止但 `|u| > static`:`net = u − sign(u)·coulomb`(突破静摩擦);
/// - 静止且 `|u| ≤ static`:`net = 0`(粘住)。
///
/// `static = coulomb = viscous = 0` 时三个分支都退化为 `net = u`,
/// 即历史的一阶模型。
pub fn step(&mut self, current_raw: i16, dt: f32) -> EscFeedback {
let applied = self.apply_delay(f32::from(current_raw));
let net = self.net_torque_raw(applied);
let target = (net * self.params.gain_rpm_per_raw)
.clamp(-self.params.max_rpm, self.params.max_rpm)
- self.params.load_rpm;
if self.params.tau_s > 0.0 && dt.is_finite() && dt > 0.0 {
let alpha = 1.0 - (-dt / self.params.tau_s).exp();
self.speed_rpm += (target - self.speed_rpm) * alpha;
} else {
// 非正时间常数 = 无惯性:立即到位(防御非法 dt)。
self.speed_rpm = target;
}
let ticks = f32::from(ENCODER_TICKS);
self.angle_raw =
(self.angle_raw + self.speed_rpm / 60.0 * ticks * dt).rem_euclid(ticks);
EscFeedback {
angle_raw: self.angle_raw as u16,
speed_rpm: self.speed_rpm as i16,
current_raw,
temperature_c: 30,
}
}
/// 命令延迟:返回 `delay_ticks` 拍之前写入的命令(未填满时返回 0)。
fn apply_delay(&mut self, u: f32) -> f32 {
let depth = self.params.delay_ticks as usize;
if depth == 0 {
return u;
}
self.delay_line.push_back(u);
while self.delay_line.len() > depth {
if let Some(delayed) = self.delay_line.pop_front() {
return delayed;
}
}
0.0
}
/// 折算到电流量纲的净驱动量(含静/库仑/黏滞摩擦)。
fn net_torque_raw(&self, u: f32) -> f32 {
let (static_raw, coulomb_raw, viscous) =
(self.params.static_raw, self.params.coulomb_raw, self.params.viscous_raw_per_rpm);
if self.speed_rpm.abs() > STICK_RPM_EPS {
let dir = if self.speed_rpm > 0.0 { 1.0 } else { -1.0 };
u - dir * coulomb_raw - viscous * self.speed_rpm
} else if u.abs() > static_raw {
let dir = if u > 0.0 { 1.0 } else { -1.0 };
u - dir * coulomb_raw
} else {
0.0
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const DT: f32 = 0.001;
/// 浮点断言辅助:避免 `float_cmp`,同时给出可读的失败信息。
#[track_caller]
fn assert_close(actual: f32, expected: f32, what: &str) {
assert!(
(actual - expected).abs() < 1e-3,
"{what}: actual {actual}, expected {expected}"
);
}
#[test]
fn ideal_model_matches_legacy_first_order_response() {
// 与历史实现(无摩擦一阶模型)逐位一致的黄金值。
let mut plant = PlantMotor::gm3508();
let gain = 9254.0 / 16384.0;
let mut expected = 0.0f32;
for _ in 0..50 {
let target = (10_000f32 * gain).clamp(-9254.0, 9254.0);
let alpha = 1.0 - (-DT / 0.12).exp();
expected += (target - expected) * alpha;
let fb = plant.step(10_000, DT);
assert_eq!(fb.speed_rpm, expected as i16, "legacy step mismatch");
}
// 50 拍(50 ms)只走完约 34% 的一阶响应 → 约 1925 rpm(远未到稳态)。
// 精确数值由上面的逐拍比对保证,这里只锁定量级。
assert!((1900.0..1950.0).contains(&plant.speed_rpm()), "rpm = {}", plant.speed_rpm());
}
#[test]
fn zero_current_stays_still() {
let mut plant = PlantMotor::gm3508();
for _ in 0..100 {
let fb = plant.step(0, DT);
assert_eq!(fb.speed_rpm, 0);
}
assert!((plant.angle_raw()).abs() < f32::EPSILON);
}
#[test]
fn static_friction_holds_below_threshold() {
let params = PlantParams::gm3508().with_friction(450.0, 500.0, 0.0);
let mut plant = PlantMotor::new(params);
for _ in 0..1000 {
plant.step(400, DT);
}
assert_close(plant.speed_rpm(), 0.0, "低于静摩擦阈值的电流不得产生运动");
}
#[test]
fn breakaway_above_static_threshold_moves() {
let params = PlantParams::gm3508().with_friction(450.0, 500.0, 0.0);
let mut plant = PlantMotor::new(params);
for _ in 0..1000 {
plant.step(1000, DT);
}
// 净驱动 = 1000 − 500 = 500 raw → 稳态 ≈ 500 × 0.565 = 282 rpm。
let expected = 500.0 * (9254.0 / 16384.0);
assert!(
(plant.speed_rpm() - expected).abs() < 5.0,
"rpm = {} expected ≈ {expected}",
plant.speed_rpm()
);
}
#[test]
fn coulomb_friction_opposes_motion_in_both_directions() {
let params = PlantParams::gm3508().with_friction(0.0, 500.0, 0.0);
let mut forward = PlantMotor::new(params);
let mut backward = PlantMotor::new(params);
for _ in 0..2000 {
forward.step(2000, DT);
backward.step(-2000, DT);
}
assert!(forward.speed_rpm() > 0.0);
assert!(backward.speed_rpm() < 0.0);
assert!((forward.speed_rpm() + backward.speed_rpm()).abs() < 1.0, "对称性");
}
#[test]
fn delay_shifts_response_by_exact_ticks() {
let immediate = PlantParams::gm3508();
let delayed = PlantParams::gm3508().with_delay_ticks(3);
let mut a = PlantMotor::new(immediate);
let mut b = PlantMotor::new(delayed);
let mut a_trace = Vec::new();
let mut b_trace = Vec::new();
for _ in 0..100 {
a_trace.push(a.step(6000, DT).speed_rpm);
b_trace.push(b.step(6000, DT).speed_rpm);
}
// 延迟 3 拍:前 3 拍反馈恒为 0,之后 b[i] == a[i-3]。
assert_eq!(&b_trace[..3], &[0, 0, 0]);
for i in 3..100 {
assert_eq!(b_trace[i], a_trace[i - 3], "tick {i}");
}
}
#[test]
fn load_rpm_offsets_steady_state() {
let unloaded = PlantParams::gm3508();
let loaded = unloaded.with_load_rpm(200.0);
let mut a = PlantMotor::new(unloaded);
let mut b = PlantMotor::new(loaded);
for _ in 0..5000 {
a.step(16384, DT);
b.step(16384, DT);
}
assert!((a.speed_rpm() - 9254.0).abs() < 0.1, "空载稳态应顶到上限: {}", a.speed_rpm());
assert!(
(b.speed_rpm() - 9054.0).abs() < 0.1,
"外载直接把稳态拉低 200 rpm: {}",
b.speed_rpm()
);
}
#[test]
fn feedback_is_quantized_by_protocol_types() {
let mut plant = PlantMotor::gm3508();
for _ in 0..10_000 {
let fb = plant.step(9000, DT);
assert!(fb.angle_raw < 8192, "angle must stay inside one revolution");
}
let fb = plant.step(9000, DT);
// 内部转速是浮点,反馈按协议被截断为 i16——量化由类型自带。
assert_eq!(fb.speed_rpm, plant.speed_rpm() as i16);
assert_eq!(fb.temperature_c, 30);
}
#[test]
fn reset_returns_to_rest() {
let mut plant = PlantMotor::gm3508();
for _ in 0..100 {
plant.step(8000, DT);
}
assert!(plant.speed_rpm() > 0.0);
plant.reset();
assert_close(plant.speed_rpm(), 0.0, "复位后转速归零");
assert_close(plant.angle_raw(), 0.0, "复位后角度归零");
}
}
}
pub mod gm3508 {
//! GM3508 的被控对象参数(ARCHITECTURE.md §5.6 / §8.2)。
//!
//! 这里只放**有实测依据**的两组参数:
//!
//! - [`ideal_params`]:无摩擦、无延迟的理想一阶模型。历史闭环测试与
//! `chassisd --mock` 依赖它,因此行为**逐位不变**;
//! - [`bench_params`]:在理想模型上叠加台架实测的静 / 库仑摩擦
//! (400~600 raw 量级)与 2 拍命令延迟,是自动整定的默认被控对象。
//!
//! 数值口径见 `docs/chassis.md` §7.4 与 `config/tune.toml`。
use super::dc_motor::PlantParams;
/// 理想模型:无摩擦、无延迟(历史默认行为)。
#[must_use]
pub fn ideal_params() -> PlantParams {
PlantParams::gm3508()
}
/// 台架量级模型:静 / 库仑摩擦 + 2 拍延迟(整定默认)。
#[must_use]
pub fn bench_params() -> PlantParams {
PlantParams::gm3508_bench()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ideal_model_has_no_friction_or_delay() {
let params = ideal_params();
assert!(params.static_raw.abs() < 1e-6);
assert!(params.coulomb_raw.abs() < 1e-6);
assert_eq!(params.delay_ticks, 0);
}
#[test]
fn bench_model_has_the_measured_friction_and_delay() {
let params = bench_params();
assert!((400.0..=600.0).contains(&params.static_raw), "{}", params.static_raw);
assert!(params.coulomb_raw > 0.0);
assert_eq!(params.delay_ticks, 2);
// 台架模型只在摩擦 / 延迟上更真实,增益与上限应保持一致。
let ideal = ideal_params();
assert!((params.gain_rpm_per_raw - ideal.gain_rpm_per_raw).abs() < 1e-9);
assert!((params.max_rpm - ideal.max_rpm).abs() < 1e-6);
}
}
}
pub use dc_motor::{PlantMotor, PlantParams};
pub use gm3508::{bench_params, ideal_params};
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "motor-protocol"
description = "DJI C620/C610 and GM6020 wire protocols"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
can = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
approx = { workspace = true }
+911
View File
@@ -0,0 +1,911 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! DJI motor wire protocols.
pub mod common {
//! DJI 电调(C620 / C610)CAN 协议编解码。
//!
//! 帧格式(大端):
//!
//! - 控制(`0x200` 电调 1–4 / `0x1FF` 电调 5–8):
//! `[I1_H, I1_L, I2_H, I2_L, I3_H, I3_L, I4_H, I4_L]`,i16 电流命令,
//! 槽位按组内电调顺序。
//! - 反馈(`0x200 + 电调ID`):
//! `u16 角度(0..8191) + i16 转速rpm + i16 转矩电流 + i8 温度℃ + 保留`。
//!
//! 本模块只做纯编解码;主机侧速度/位置环由 `motor-control` 的算法在
//! `motor / esc-bus` 的总线驱动中按节拍调用。
use crate::frame::{
COMMAND_FRAME_ID_PRIMARY, COMMAND_FRAME_ID_SECONDARY, CanFrame, FEEDBACK_FRAME_ID_BASE,
};
use thiserror::Error;
/// 电调 ID 下限。
pub const MIN_ESC_ID: u8 = 1;
/// 电调 ID 上限。
pub const MAX_ESC_ID: u8 = 8;
/// 电调型号决定电流标度。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EscModel {
/// C620(配 GM3508):±16384 ↔ ±20 A。
C620,
/// C610(配 M2006):±10000 ↔ ±10 A。
///
/// 接入前需实测核对。
C610,
}
impl EscModel {
/// 满量程电流命令(raw)。
#[must_use]
pub const fn max_current_raw(self) -> i16 {
match self {
Self::C620 => 16384,
Self::C610 => 10_000,
}
}
/// 满量程电流(安培)。
#[must_use]
pub const fn max_current_amp(self) -> f32 {
match self {
Self::C620 => 20.0,
Self::C610 => 10.0,
}
}
/// 安培 → raw(限幅)。
#[must_use]
pub fn raw_from_amp(self, amp: f32) -> i16 {
let raw = amp * f32::from(self.max_current_raw()) / self.max_current_amp();
(raw.clamp(-f32::from(self.max_current_raw()), f32::from(self.max_current_raw()))
as i16)
.clamp(-self.max_current_raw(), self.max_current_raw())
}
/// raw → 安培。
#[must_use]
pub fn amp_from_raw(self, raw: i16) -> f32 {
f32::from(raw) * self.max_current_amp() / f32::from(self.max_current_raw())
}
}
/// 一帧电调反馈(原始、电调侧,未经方向修正)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EscFeedback {
/// 转子机械角原始值(0..8191,对应 0..360°)。
pub angle_raw: u16,
/// 转子转速(rpm,电调侧符号)。
pub speed_rpm: i16,
/// 转矩电流 raw(电调侧符号)。
pub current_raw: i16,
/// 电机温度(℃)。
pub temperature_c: i8,
}
/// 帧解码错误。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum DecodeError {
/// 帧不属于电调反馈范围(0x201..=0x208)。
#[error("frame id 0x{id:03X} is not an ESC feedback frame (0x201..=0x208)")]
NotFeedback {
/// 收到的帧 ID。
id: u32,
},
/// 数据长度不足 8 字节(协议载荷固定 8 字节)。
#[error("frame carries only {dlc} bytes; the ESC feedback payload needs 8")]
ShortFrame {
/// 实际数据长度码。
dlc: u8,
},
}
/// 电调 ID → 其电流命令所在的组控制帧 ID。
///
/// 返回 `None` 表示 ID 越界。
#[must_use]
pub const fn command_frame_id(esc_id: u8) -> Option<u32> {
match esc_id {
1..=4 => Some(COMMAND_FRAME_ID_PRIMARY),
5..=8 => Some(COMMAND_FRAME_ID_SECONDARY),
_ => None,
}
}
/// 电调 ID → 其反馈帧 ID(`0x200 + 电调ID`)。
///
/// 返回 `None` 表示 ID 越界。GM6020 用**另一张表**,见
/// [`crate::gm6020::feedback_frame_id`]。
#[must_use]
pub const fn feedback_frame_id(esc_id: u8) -> Option<u32> {
if esc_id >= MIN_ESC_ID && esc_id <= MAX_ESC_ID {
Some(FEEDBACK_FRAME_ID_BASE + esc_id as u32)
} else {
None
}
}
/// 将 raw 电流限幅到电调满量程。
#[must_use]
pub const fn clamp_current(model: EscModel, raw: i16) -> i16 {
if raw > model.max_current_raw() {
model.max_current_raw()
} else if raw < -model.max_current_raw() {
-model.max_current_raw()
} else {
raw
}
}
/// 组装一个控制组帧(`0x200` 或 `0x1FF`)。
///
/// `currents` 按组内槽位排列:主组对应电调 1–4,副组对应电调 5–8。
/// 本函数只做纯编码;电流限幅由驱动层按各电机型号与限流配置完成
/// (见 [`clamp_current`])。
#[must_use]
pub fn encode_current_frame(frame_id: u32, currents: [i16; 4]) -> CanFrame {
let mut data = [0u8; 8];
for (slot, raw) in currents.iter().enumerate() {
data[slot * 2] = (raw >> 8) as u8;
data[slot * 2 + 1] = (raw & 0xFF) as u8;
}
CanFrame::new(frame_id, data)
}
/// 组装一帧电调反馈(仿真器 / 测试用)。
#[must_use]
pub fn encode_feedback_frame(esc_id: u8, feedback: &EscFeedback) -> CanFrame {
let mut data = [0u8; 8];
data[0..2].copy_from_slice(&feedback.angle_raw.to_be_bytes());
data[2..4].copy_from_slice(&feedback.speed_rpm.to_be_bytes());
data[4..6].copy_from_slice(&feedback.current_raw.to_be_bytes());
data[6] = feedback.temperature_c as u8;
CanFrame::new(FEEDBACK_FRAME_ID_BASE + u32::from(esc_id), data)
}
/// 解码一帧电调反馈,返回 `(电调ID, 反馈)`。
///
/// # Errors
///
/// 帧不属于反馈 ID 范围(0x201..=0x208)时返回 [`DecodeError::NotFeedback`];
/// 数据长度不足 8 字节时返回 [`DecodeError::ShortFrame`]。
pub fn decode_feedback(frame: &CanFrame) -> Result<(u8, EscFeedback), DecodeError> {
if !frame.is_complete() {
return Err(DecodeError::ShortFrame { dlc: frame.dlc });
}
if frame.id <= FEEDBACK_FRAME_ID_BASE
|| frame.id > FEEDBACK_FRAME_ID_BASE + u32::from(MAX_ESC_ID)
{
return Err(DecodeError::NotFeedback { id: frame.id });
}
let esc_id = (frame.id - FEEDBACK_FRAME_ID_BASE) as u8;
Ok((
esc_id,
EscFeedback {
angle_raw: u16::from_be_bytes([frame.data[0], frame.data[1]]),
speed_rpm: i16::from_be_bytes([frame.data[2], frame.data[3]]),
current_raw: i16::from_be_bytes([frame.data[4], frame.data[5]]),
temperature_c: frame.data[6] as i8,
},
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_frame_id_routing() {
assert_eq!(command_frame_id(1), Some(0x200));
assert_eq!(command_frame_id(4), Some(0x200));
assert_eq!(command_frame_id(5), Some(0x1FF));
assert_eq!(command_frame_id(8), Some(0x1FF));
assert_eq!(command_frame_id(0), None);
assert_eq!(command_frame_id(9), None);
}
#[test]
fn encode_current_frame_bytes() {
let frame = encode_current_frame(0x200, [16384, -16384, 0, 258]);
assert_eq!(frame.id, 0x200);
assert_eq!(frame.data, [0x40, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x01, 0x02]);
let frame = encode_current_frame(0x1FF, [10000, -10000, 0, 0]);
assert_eq!(frame.id, 0x1FF);
assert_eq!(frame.data, [0x27, 0x10, 0xD8, 0xF0, 0x00, 0x00, 0x00, 0x00]);
}
#[test]
fn decode_feedback_known_bytes() {
let frame = CanFrame::new(0x205, [0x1F, 0xFF, 0xFE, 0x94, 0x00, 0x7B, 45, 0]);
let (esc_id, fb) = decode_feedback(&frame).unwrap();
assert_eq!(esc_id, 5);
assert_eq!(fb.angle_raw, 8191);
assert_eq!(fb.speed_rpm, -364);
assert_eq!(fb.current_raw, 123);
assert_eq!(fb.temperature_c, 45);
}
#[test]
fn decode_feedback_rejects_non_feedback_ids() {
for id in [0x000, 0x1FF, 0x200, 0x209, 0x7FF] {
let frame = CanFrame::new(id, [0; 8]);
assert_eq!(
decode_feedback(&frame),
Err(DecodeError::NotFeedback { id }),
"id = 0x{id:03X}"
);
}
}
#[test]
fn feedback_roundtrip_all_ids() {
let fb = EscFeedback {
angle_raw: 8191,
speed_rpm: -9254,
current_raw: -16384,
temperature_c: -40,
};
for esc_id in MIN_ESC_ID..=MAX_ESC_ID {
let frame = encode_feedback_frame(esc_id, &fb);
assert_eq!(frame.id, 0x200 + u32::from(esc_id));
assert_eq!(decode_feedback(&frame).unwrap(), (esc_id, fb));
}
}
#[test]
fn amp_conversion() {
let c620 = EscModel::C620;
assert!((c620.amp_from_raw(16384) - 20.0).abs() < 1e-4);
assert!((c620.amp_from_raw(-8192) + 10.0).abs() < 1e-4);
assert_eq!(c620.raw_from_amp(20.0), 16384);
assert_eq!(c620.raw_from_amp(-5.0), -4096);
// 超量程限幅。
assert_eq!(c620.raw_from_amp(99.0), 16384);
let c610 = EscModel::C610;
assert!((c610.amp_from_raw(10000) - 10.0).abs() < 1e-4);
assert_eq!(c610.raw_from_amp(10.0), 10000);
}
#[test]
fn clamp_current_respects_model() {
assert_eq!(clamp_current(EscModel::C620, 20000), 16384);
assert_eq!(clamp_current(EscModel::C610, 15000), 10000);
assert_eq!(clamp_current(EscModel::C620, 1000), 1000);
}
}
}
pub mod gm6020 {
//! GM6020 云台电机协议(ARCHITECTURE.md §5.2)。
//!
//! 与 C620/C610 的差别只有两点,因此两个协议族共享同一套帧布局:
//!
//! | 项目 | C620/C610(M3508/M2006) | GM6020 |
//! | --- | --- | --- |
//! | 控制量 | **电流**(±16384 / ±10000 ↔ 满量程电流) | **电压**(±30000 ↔ 满电压) |
//! | 控制帧 ID | `0x200`(1–4)、`0x1FF`(5–8) | `0x1FF`(1–4)、`0x2FF`(5–8) |
//! | 反馈帧 ID | `0x200 + ID`(0x201–0x208) | `0x204 + ID`(0x205–0x208,ID 1–4) |
//! | 反馈载荷 | u16 角度 + i16 转速 + i16 电流 + i8 温度 | 同上 |
//!
//! > 命名说明:ARCHITECTURE.md §5.2 把命令字段写作 `current`,但 GM6020 的
//! > 控制字是**电压**占空比命令(±30000)。这里用 `voltage_raw` 以免把
//! > "电流环在电调内部"这件事记反——GM6020 内部同样只闭电流环。
use can::CanFrame;
use crate::common::{DecodeError, MAX_ESC_ID, MIN_ESC_ID};
use crate::id::MotorId;
/// 控制帧 ID:GM6020 ID 1–4。
pub const COMMAND_FRAME_ID_PRIMARY: u32 = 0x1FF;
/// 控制帧 ID:GM6020 ID 5–8。
pub const COMMAND_FRAME_ID_SECONDARY: u32 = 0x2FF;
/// 反馈帧 ID 基址:`0x204 + ID`(ID 1–4 → 0x205–0x208)。
pub const FEEDBACK_FRAME_ID_BASE: u32 = 0x204;
/// 电压命令满量程(raw)。
pub const VOLTAGE_MAX_RAW: i16 = 30_000;
/// 编码器单圈刻度(与 C620 相同)。
pub const ENCODER_TICKS: u16 = 8192;
/// GM6020 一帧反馈。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Gm6020Feedback {
/// 机械角(度,0..360)。
pub angle_deg: f32,
/// 单圈原始角度(0..8191)。
pub angle_raw: u16,
/// 转子转速(rpm)。
pub speed_rpm: i16,
/// 转矩电流(raw,±30000 ↔ 满量程)。
pub current_raw: i16,
/// 温度(℃)。
pub temperature_c: i8,
}
/// GM6020 电压命令。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Gm6020Command {
/// 电压命令(raw,`-30000..=30000`)。
pub voltage_raw: i16,
}
impl Gm6020Command {
/// 构造并夹到合法范围。
#[must_use]
pub const fn new(voltage_raw: i16) -> Self {
Self { voltage_raw: clamp_voltage(voltage_raw) }
}
/// 编码为**广播控制帧**(同组四个槽位)。
///
/// # Errors
///
/// 帧 ID 非法时返回 [`DecodeError::NotFeedback`] 之外的 `BadFrameId`。
///
/// 说明:单电机编码只是"其余槽位填 0"的特例,因此本函数要求调用方
/// 提供整组电压;单电机场景见 [`Gm6020Command::to_frame`]。
#[must_use]
pub fn encode_group(frame_id: u32, voltages: [i16; 4]) -> CanFrame {
encode_voltage_frame(frame_id, voltages)
}
/// 单电机编码:其余槽位补 0(会**覆盖**同组其它电机,仅用于单机联调)。
///
/// 注意用的是 **GM6020 自己的**控制帧映射(`0x1FF` / `0x2FF`),
/// 而不是 [`MotorId::command_frame_id`] 的 C620 映射(`0x200` / `0x1FF`)——
/// 两个协议族的帧 ID 表不同,混淆会导致云台收不到任何命令。
#[must_use]
pub fn to_frame<const ID: u8>(&self, id: MotorId<ID>) -> CanFrame {
let esc_id = id.get();
let mut voltages = [0i16; 4];
let slot = command_slot(esc_id).unwrap_or(0);
voltages[slot] = self.voltage_raw;
let frame_id = command_frame_id(esc_id).unwrap_or(COMMAND_FRAME_ID_PRIMARY);
encode_voltage_frame(frame_id, voltages)
}
}
/// 夹到电压满量程。
#[must_use]
pub const fn clamp_voltage(raw: i16) -> i16 {
// `i16::clamp` 在 const fn 里尚不可用(Ord 未 const 稳定),手写三段。
if raw > VOLTAGE_MAX_RAW {
VOLTAGE_MAX_RAW
} else if raw < -VOLTAGE_MAX_RAW {
-VOLTAGE_MAX_RAW
} else {
raw
}
}
/// 由电调 ID 推出控制帧槽位(`None` 表示 ID 越界)。
#[must_use]
pub const fn command_slot(esc_id: u8) -> Option<usize> {
if esc_id >= MIN_ESC_ID && esc_id <= 4 {
Some((esc_id - 1) as usize)
} else if esc_id >= 5 && esc_id <= MAX_ESC_ID {
Some((esc_id - 5) as usize)
} else {
None
}
}
/// 由电调 ID 推出控制帧 ID(`None` 表示 ID 越界)。
#[must_use]
pub const fn command_frame_id(esc_id: u8) -> Option<u32> {
match command_slot(esc_id) {
Some(_) if esc_id <= 4 => Some(COMMAND_FRAME_ID_PRIMARY),
Some(_) => Some(COMMAND_FRAME_ID_SECONDARY),
None => None,
}
}
/// 反馈帧 ID(`0x204 + ID`;ID 5–8 为 `0x208 + ID`,见模块文档)。
#[must_use]
pub const fn feedback_frame_id(esc_id: u8) -> Option<u32> {
if esc_id >= MIN_ESC_ID && esc_id <= 4 {
Some(FEEDBACK_FRAME_ID_BASE + esc_id as u32)
} else if esc_id >= 5 && esc_id <= MAX_ESC_ID {
Some(0x208 + esc_id as u32)
} else {
None
}
}
/// 编码一组电压命令(4 × i16 大端)。
#[must_use]
pub fn encode_voltage_frame(frame_id: u32, voltages: [i16; 4]) -> CanFrame {
let mut data = [0u8; 8];
for (slot, raw) in voltages.iter().enumerate() {
let clamped = clamp_voltage(*raw);
data[slot * 2] = (clamped >> 8) as u8;
data[slot * 2 + 1] = (clamped & 0xFF) as u8;
}
CanFrame::new(frame_id, data)
}
/// 组装一帧 GM6020 反馈(仿真器 / 测试用)。
#[must_use]
pub fn encode_feedback_frame(esc_id: u8, feedback: &Gm6020Feedback) -> CanFrame {
let mut data = [0u8; 8];
data[0..2].copy_from_slice(&feedback.angle_raw.to_be_bytes());
data[2..4].copy_from_slice(&feedback.speed_rpm.to_be_bytes());
data[4..6].copy_from_slice(&feedback.current_raw.to_be_bytes());
data[6] = feedback.temperature_c as u8;
CanFrame::new(FEEDBACK_FRAME_ID_BASE + u32::from(esc_id), data)
}
/// 解码一帧 GM6020 反馈,返回 `(电调ID, 反馈)`。
///
/// # Errors
///
/// 帧 ID 不在 GM6020 反馈范围(0x205–0x208、0x20D–0x210)内时返回
/// [`DecodeError::NotFeedback`];长度不足时返回 [`DecodeError::ShortFrame`]。
pub fn decode_feedback(frame: &CanFrame) -> Result<(u8, Gm6020Feedback), DecodeError> {
if !frame.is_complete() {
return Err(DecodeError::ShortFrame { dlc: frame.dlc });
}
let esc_id = if (0x205..=0x208).contains(&frame.id) {
(frame.id - FEEDBACK_FRAME_ID_BASE) as u8
} else if (0x20D..=0x210).contains(&frame.id) {
(frame.id - 0x208) as u8
} else {
return Err(DecodeError::NotFeedback { id: frame.id });
};
let angle_raw = u16::from_be_bytes([frame.data[0], frame.data[1]]);
let speed_rpm = i16::from_be_bytes([frame.data[2], frame.data[3]]);
let current_raw = i16::from_be_bytes([frame.data[4], frame.data[5]]);
let temperature_c = frame.data[6] as i8;
Ok((
esc_id,
Gm6020Feedback {
angle_raw: angle_raw % ENCODER_TICKS,
angle_deg: f32::from(angle_raw) * 360.0 / f32::from(ENCODER_TICKS),
speed_rpm,
current_raw,
temperature_c,
},
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn voltage_is_clamped_to_full_scale() {
assert_eq!(Gm6020Command::new(32_000).voltage_raw, VOLTAGE_MAX_RAW);
assert_eq!(Gm6020Command::new(-32_000).voltage_raw, -VOLTAGE_MAX_RAW);
assert_eq!(Gm6020Command::new(1234).voltage_raw, 1234);
}
#[test]
fn single_motor_encoding_lands_in_the_right_slot() {
let frame = Gm6020Command::new(1000).to_frame(MotorId::<1>::NEW);
assert_eq!(frame.id, 0x1FF);
assert_eq!(i16::from_be_bytes([frame.data[0], frame.data[1]]), 1000);
assert_eq!(i16::from_be_bytes([frame.data[2], frame.data[3]]), 0);
let frame = Gm6020Command::new(-1000).to_frame(MotorId::<6>::NEW);
assert_eq!(frame.id, 0x2FF, "ID 5–8 走副组帧");
assert_eq!(i16::from_be_bytes([frame.data[2], frame.data[3]]), -1000);
}
#[test]
fn group_encoding_matches_slot_order() {
let frame = encode_voltage_frame(COMMAND_FRAME_ID_PRIMARY, [10, -20, 30, -40]);
assert_eq!(i16::from_be_bytes([frame.data[0], frame.data[1]]), 10);
assert_eq!(i16::from_be_bytes([frame.data[2], frame.data[3]]), -20);
assert_eq!(i16::from_be_bytes([frame.data[4], frame.data[5]]), 30);
assert_eq!(i16::from_be_bytes([frame.data[6], frame.data[7]]), -40);
}
#[test]
fn feedback_round_trips_and_converts_angle() {
let feedback = Gm6020Feedback {
angle_deg: 0.0,
angle_raw: 2048,
speed_rpm: -321,
current_raw: 4567,
temperature_c: 42,
};
let frame = encode_feedback_frame(2, &feedback);
assert_eq!(frame.id, 0x206);
let (id, decoded) = decode_feedback(&frame).unwrap();
assert_eq!(id, 2);
assert_eq!(decoded.angle_raw, 2048);
assert!((decoded.angle_deg - 90.0).abs() < 1e-3, "2048/8192 应为 90°");
assert_eq!(decoded.speed_rpm, -321);
assert_eq!(decoded.current_raw, 4567);
assert_eq!(decoded.temperature_c, 42);
}
#[test]
fn feedback_id_range_is_enforced() {
assert_eq!(feedback_frame_id(1), Some(0x205));
assert_eq!(feedback_frame_id(4), Some(0x208));
assert_eq!(feedback_frame_id(9), None);
let c620 = CanFrame::new(0x201, [0; 8]);
assert!(matches!(decode_feedback(&c620), Err(DecodeError::NotFeedback { .. })));
}
#[test]
fn short_frames_are_rejected() {
let short = CanFrame::new(0x205, [0; 8]).with_dlc(4);
assert!(matches!(decode_feedback(&short), Err(DecodeError::ShortFrame { .. })));
}
}
}
pub mod m2006 {
//! M2006 + C610:拨弹 / 小负载电机。本项目只用到协议常量,用于未来的
//! 发射机构(发射机构尚未实现,见 ARCHITECTURE.md §5.7 的 `shooter` 任务)。
use crate::common::EscModel;
/// C610 电调型号(M2006 的配套电调)。
///
/// 电流标度 ±10000 ↔ ±10 A **接入前需实测核对**(ARCHITECTURE.md §2.2 注)。
pub const C610: EscModel = EscModel::C610;
/// 减速比(转子 : 输出轴)= 36 : 1(M2006 常见配置,接硬件前核对)。
pub const GEAR_RATIO: f32 = 36.0;
/// 编码器单圈刻度。
pub const ENCODER_TICKS: u16 = 8192;
/// 额定电流满量程(A,对应 raw 满量程 ±10000)。
pub const MAX_CURRENT_AMP: f32 = 10.0;
#[cfg(test)]
mod tests {
use super::*;
use crate::common::clamp_current;
#[test]
fn c610_saturation_uses_the_c610_scale() {
assert_eq!(C610.max_current_raw(), 10_000);
assert_eq!(clamp_current(C610, 20_000), 10_000);
assert_eq!(clamp_current(C610, -20_000), -10_000);
}
#[test]
fn max_current_amp_matches_the_raw_scale() {
assert!((C610.max_current_amp() - MAX_CURRENT_AMP).abs() < 1e-6);
}
}
}
pub mod m3508 {
//! GM3508 + C620:底盘驱动轮的电流协议族(本项目的主力电机)。
//!
//! 全部编解码都在 [`crate::common`](C620 与 C610 帧布局相同,只有
//! 电流标度不同),本模块只固定 GM3508 的**型号常量与机械参数**,避免
//! 每个使用点各自抄一遍数字。
use crate::common::EscModel;
/// C620 电调型号(GM3508 的配套电调)。
pub const C620: EscModel = EscModel::C620;
/// 减速比(转子 : 输出轴)= 3591 / 187 ≈ 19.203。
pub const GEAR_RATIO: f32 = 3591.0 / 187.0;
/// 编码器单圈刻度。
pub const ENCODER_TICKS: u16 = 8192;
/// 额定电流满量程(A,对应 raw 满量程 ±16384)。
pub const MAX_CURRENT_AMP: f32 = 20.0;
/// 空载转速-电流增益(rpm / raw),来自台架实测(≈ 9254 / 16384)。
pub const NO_LOAD_GAIN_RPM_PER_RAW: f32 = 9254.0 / 16384.0;
/// 空载转速上限(rpm,转子)。
pub const NO_LOAD_MAX_RPM: f32 = 9254.0;
#[cfg(test)]
mod tests {
use super::*;
use crate::common::clamp_current;
#[test]
fn gear_ratio_matches_the_manual() {
assert!((GEAR_RATIO - 19.203).abs() < 5e-3, "{GEAR_RATIO}");
}
#[test]
fn c620_saturation_uses_the_c620_scale() {
assert_eq!(C620.max_current_raw(), 16384);
assert_eq!(clamp_current(C620, 30_000), 16384);
assert_eq!(clamp_current(C620, -30_000), -16384);
}
#[test]
fn no_load_gain_is_the_inverse_of_the_documented_feedforward() {
// 台架文档里的前馈 ≈ 1.77 raw/rpm 正是 1 / 空载增益。
let feedforward = 1.0 / NO_LOAD_GAIN_RPM_PER_RAW;
assert!((feedforward - 1.77).abs() < 0.02, "{feedforward}");
}
}
}
pub mod frame {
//! 协议帧 ID 常量与帧类型再导出。
//!
//! 帧类型([`CanFrame`])由 L1 的 [`can`] 提供;本模块只保留各协议族的
//! **ID 约定**,因此"改 ID 常量"与"改帧结构"是两件互不影响的事。
//!
//! - C620/C610 电流协议族:本模块的常量 + [`crate::common`];
//! - GM6020 电压协议族:见 [`crate::gm6020`](ID 常量在那边);
//! - DETA10 IMU:见 [`crate::delta10`]。
pub use can::CanFrame;
/// 控制帧 ID:电调 1–4 的电流命令(8 字节 = 4 × i16 大端)。
pub const COMMAND_FRAME_ID_PRIMARY: u32 = 0x200;
/// 控制帧 ID:电调 5–8 的电流命令。
pub const COMMAND_FRAME_ID_SECONDARY: u32 = 0x1FF;
/// 反馈帧 ID 基址:`0x200 + 电调ID`(0x201–0x208)。
pub const FEEDBACK_FRAME_ID_BASE: u32 = 0x200;
}
pub mod id {
//! 编译期电机 ID 校验(ARCHITECTURE.md §5.2)。
//!
//! 电调 ID 写错是实车上最常见的低级错误之一:ID 9 不会报错,只会**静默**
//! 收不到反馈。把 ID 放进 const 泛型后,"ID 必须在 1–8"变成**编译期**约束,
//! 接错线之前先接错类型。
//!
//! ```ignore
//! // 编译通过
//! let id = MotorId::<3>::NEW;
//! // 编译失败:Motor ID must be 1-8
//! let bad = MotorId::<9>::NEW;
//! ```
/// 合法的电调 ID 范围。
pub const MOTOR_ID_RANGE: core::ops::RangeInclusive<u8> = 1..=8;
/// 编译期已校验的电调 ID。
///
/// 只能通过 [`MotorId::NEW`] 构造;该常量的求值会断言 ID 在 1–8 之间,
/// 越界即编译失败。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct MotorId<const ID: u8>;
impl<const ID: u8> MotorId<ID> {
/// 编译期校验并构造。
///
/// # Panics
///
/// `ID` 不在 `1..=8` 时在**编译期** panic(const 求值失败即编译错误)。
pub const NEW: Self = {
assert!(ID >= 1 && ID <= 8, "Motor ID must be 1-8");
Self
};
/// 取出 ID 数值。
#[must_use]
pub const fn get(&self) -> u8 {
ID
}
/// 所属控制帧 ID(**C620/C610 电流协议族**:主组 `0x200` / 副组 `0x1FF`)。
///
/// GM6020 的电压协议族用的是**另一张表**(`0x1FF` / `0x2FF`),见
/// [`crate::gm6020::command_frame_id`]。两者不可混用。
#[must_use]
pub const fn command_frame_id(&self) -> u32 {
if ID <= 4 {
crate::frame::COMMAND_FRAME_ID_PRIMARY
} else {
crate::frame::COMMAND_FRAME_ID_SECONDARY
}
}
/// 组内槽位(0..4),即控制帧里第几个 i16。
#[must_use]
pub const fn slot(&self) -> usize {
if ID <= 4 { (ID - 1) as usize } else { (ID - 5) as usize }
}
/// 反馈帧 ID(`0x200 + ID`)。
#[must_use]
pub const fn feedback_frame_id(&self) -> u32 {
crate::frame::FEEDBACK_FRAME_ID_BASE + ID as u32
}
}
/// 运行期已知 ID 的校验(用于读配置 / 解析命令行)。
///
/// # Errors
///
/// ID 不在 `1..=8` 时返回原值,便于报错信息带上用户输入。
pub const fn validate_motor_id(id: u8) -> Result<u8, u8> {
if id >= 1 && id <= 8 { Ok(id) } else { Err(id) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn primary_and_secondary_groups_split_correctly() {
assert_eq!(MotorId::<1>::NEW.command_frame_id(), 0x200);
assert_eq!(MotorId::<4>::NEW.command_frame_id(), 0x200);
assert_eq!(MotorId::<5>::NEW.command_frame_id(), 0x1FF);
assert_eq!(MotorId::<8>::NEW.command_frame_id(), 0x1FF);
assert_eq!(MotorId::<1>::NEW.slot(), 0);
assert_eq!(MotorId::<4>::NEW.slot(), 3);
assert_eq!(MotorId::<5>::NEW.slot(), 0);
assert_eq!(MotorId::<8>::NEW.slot(), 3);
}
#[test]
fn feedback_ids_follow_0x200_plus_id() {
assert_eq!(MotorId::<1>::NEW.feedback_frame_id(), 0x201);
assert_eq!(MotorId::<8>::NEW.feedback_frame_id(), 0x208);
assert_eq!(MotorId::<3>::NEW.get(), 3);
}
#[test]
fn runtime_validation_returns_the_offending_id() {
assert_eq!(validate_motor_id(1), Ok(1));
assert_eq!(validate_motor_id(8), Ok(8));
assert_eq!(validate_motor_id(0), Err(0));
assert_eq!(validate_motor_id(9), Err(9));
}
}
}
pub mod turn {
//! 多圈累计:单圈编码器原始角 → 连续圈数。
/// C620/C610 反馈编码器单圈计数(0..8191 对应 0..360°)。
pub const ENCODER_TICKS: u16 = 8192;
/// 半圈阈值:帧间角变化超过它视为发生回绕。
const HALF_TURN_TICKS: i32 = 4096;
/// 单圈机械角跟踪器:把 0..8191 的原始角序列转成连续累计角(单位:圈)。
///
/// 假设相邻两帧间转子转动不超过半圈。1kHz 反馈下 GM3508 转子上限约
/// 9300 rpm ≈ 0.155 圈/ms,余量充足。
#[derive(Debug, Clone, Default)]
pub struct TurnTracker {
revolutions: i32,
prev_raw: Option<u16>,
last_angle_rev: f32,
}
impl TurnTracker {
/// 创建跟踪器。
#[must_use]
pub const fn new() -> Self {
Self { revolutions: 0, prev_raw: None, last_angle_rev: 0.0 }
}
/// 送入新的原始角,返回连续累计角(圈)。
pub fn update(&mut self, raw: u16) -> f32 {
if let Some(prev) = self.prev_raw {
let delta = i32::from(raw) - i32::from(prev);
if delta > HALF_TURN_TICKS {
// 正向跨越 8191 → 0。
self.revolutions -= 1;
} else if delta < -HALF_TURN_TICKS {
// 反向跨越 0 → 8191。
self.revolutions += 1;
}
}
self.prev_raw = Some(raw);
self.last_angle_rev = self.revolutions as f32 + f32::from(raw) / 8192.0;
self.last_angle_rev
}
/// 最近一次 [`TurnTracker::update`] 的返回值。
#[must_use]
pub const fn last_angle_rev(&self) -> f32 {
self.last_angle_rev
}
/// 清零回到初始状态。
pub fn reset(&mut self) {
*self = Self::new();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_reading_is_raw_fraction() {
let mut t = TurnTracker::new();
assert!((t.update(4096) - 0.5).abs() < 1e-4);
}
#[test]
fn forward_wrap_counts_up() {
let mut t = TurnTracker::new();
t.update(8000);
let after = t.update(100); // 8000 → 8191 → 0 → 100
assert!((after - (8192.0 + 100.0) / 8192.0).abs() < 1e-4, "{after}");
}
#[test]
fn reverse_wrap_counts_down() {
let mut t = TurnTracker::new();
t.update(100);
let after = t.update(8000); // 100 → 0 → 8191 → 8000(反向)
assert!((after - (8000.0 - 8192.0) / 8192.0).abs() < 1e-4, "{after}");
}
#[test]
fn small_deltas_do_not_wrap() {
let mut t = TurnTracker::new();
t.update(0);
assert!((t.update(4000) - 4000.0 / 8192.0).abs() < 1e-4);
assert!((t.update(4100) - 4100.0 / 8192.0).abs() < 1e-4);
}
#[test]
fn multi_turn_forward_then_reverse_returns() {
let mut t = TurnTracker::new();
// 正向 3 整圈:每圈 0 → 4000 → 8000 → 100(跨 8191 回绕,净 +1)。
for _ in 0..3 {
t.update(0);
t.update(4000);
t.update(8000);
t.update(100);
}
let fwd = t.last_angle_rev();
let expected_fwd = 3.0 + 100.0 / 8192.0;
assert!((fwd - expected_fwd).abs() < 1e-4, "{fwd} vs {expected_fwd}");
// 沿同一路径反向退回 3 圈:100 → 8000(反向回绕)→ 4000 → 0,净 -1/圈。
for _ in 0..3 {
t.update(8000);
t.update(4000);
t.update(0);
}
let back = t.last_angle_rev();
assert!(back.abs() < 1e-4, "should return to 0, got {back}");
}
#[test]
fn jitter_at_boundary_stays_put() {
// 0 附近抖动不产生虚假圈数。
let mut t = TurnTracker::new();
t.update(8191);
assert!((t.update(0) - 1.0).abs() < 1e-4); // 真实跨越一次
assert!((t.update(2) - (2.0 / 8192.0 + 1.0)).abs() < 1e-4);
assert!((t.update(8190) - (8190.0 / 8192.0)).abs() < 1e-4); // 抖回
assert!((t.update(5) - (1.0 + 5.0 / 8192.0)).abs() < 1e-4);
}
#[test]
fn reset_forgets_history() {
let mut t = TurnTracker::new();
t.update(8000);
t.update(100);
t.reset();
assert_eq!(t.last_angle_rev().to_bits(), 0.0f32.to_bits());
assert!((t.update(4096) - 0.5).abs() < 1e-4);
}
}
}
pub use common::{
DecodeError, EscFeedback, EscModel, MAX_ESC_ID, MIN_ESC_ID, clamp_current, command_frame_id,
decode_feedback, encode_current_frame, encode_feedback_frame, feedback_frame_id,
};
pub use frame::*;
pub use gm6020::{Gm6020Command, Gm6020Feedback};
pub use id::*;
pub use m2006::C610;
pub use m3508::C620;
pub use turn::*;
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "motor"
description = "Protocol-independent motor device contract and registry"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
can = { workspace = true }
motor-protocol = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
+964
View File
@@ -0,0 +1,964 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! Protocol-independent motor device contracts.
pub mod traits {
//! 能力面 trait:小而正交,按硬件事实拆分。
//!
//! 消费者只声明自己用到的能力(静态分发,无 `dyn`):
//! - 底盘轮 → [`VelocityDrive`] + [`MotorFeedback`]
//! - 云台 → [`PositionDrive`] + [`EffortDrive`](+ 上层 [`crate::HeadingSource`])
//! - 拨弹 → [`VelocityDrive`] + [`PositionDrive`]
//! - 台架 → [`VelocityDrive`] + [`MotorFeedback`] + [`Disable`]
use std::time::Duration;
use crate::{Command, MotorState};
/// 读反馈。所有可观察设备的最小面。
pub trait MotorFeedback: Send + Sync {
/// 控制器内的电机 ID(对 DJI 电调即电调 ID)。
fn id(&self) -> u8;
/// 最新反馈快照。
fn state(&self) -> MotorState;
/// 距最近一帧反馈的时长;`None` 表示从未收到。
///
/// 用于在线判定(反馈新鲜度),与 [`MotorState::has_feedback`]
/// (历史上是否收到过)区分:上电门控之后运行中掉线要看本方法。
fn feedback_age(&self) -> Option<Duration>;
}
/// 开环控制量(协议 raw:ESC=电流,GM6020=电压)。
pub trait EffortDrive: Send + Sync {
/// 直接下发开环 raw(协议量程,物理方向)。
fn set_effort_raw(&self, raw: i16);
}
/// 速度目标(转子 rpm,物理方向);由驱动层速度环跟踪。
pub trait VelocityDrive: Send + Sync {
/// 设定目标转子转速。
fn set_velocity(&self, rotor_rpm: f32);
}
/// 位置目标(转子多圈,圈;物理方向)。
pub trait PositionDrive: Send + Sync {
/// 设定目标转子多圈位置。
fn set_position(&self, rotor_rev: f32);
}
/// 停用(零输出)。
pub trait Disable: Send + Sync {
/// 停用电机。
fn disable(&self);
}
/// 便利别名(blanket impl,零成本)。实现方**不必**实现它。
pub trait Motor: MotorFeedback + VelocityDrive + EffortDrive + Disable {}
impl<T> Motor for T where T: MotorFeedback + VelocityDrive + EffortDrive + Disable {}
/// One motor's data for one control-policy evaluation.
#[derive(Debug, Clone, Copy)]
pub struct PolicySample {
/// ESC identifier.
pub id: u8,
/// Latest direction-corrected physical feedback.
pub state: MotorState,
/// Latest command lease value.
pub command: Command,
/// Whether fresh feedback is available.
pub online: bool,
}
/// Snapshot passed to an external effort policy once per bus tick.
#[derive(Debug, Clone, Copy)]
pub struct PolicyInput {
/// Samples indexed by ESC id minus one.
pub samples: [PolicySample; 8],
/// Measured control period in seconds.
pub dt: f32,
}
/// Computes raw physical effort from motor commands and feedback.
///
/// `esc-bus` owns transport and safety but deliberately has no control-law
/// dependency. Control crates implement this trait and the application composes
/// the two sides.
pub trait EffortPolicy: Send + 'static {
/// Return physical effort raw values indexed by ESC id minus one.
fn step(&mut self, input: &PolicyInput) -> [i16; 8];
/// Clear controller state after an offline event or command-mode change.
fn reset(&mut self, esc_id: u8);
}
}
pub mod state {
//! 电机反馈快照(协议无关,方向修正后的物理量)。
//!
//! 只含**转子轴**物理量——减速比 / 输出轴换算由 [`crate::gearbox::OutputShaft`]
//! 或上层完成,不在此泄漏底盘语义。
/// 电机反馈快照。
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct MotorState {
/// 最近一帧反馈的转子机械原始角(编码器计数,量程由具体协议决定)。
pub rotor_angle_raw: u16,
/// 连续累计转子角(多圈回绕处理,单位:圈;随时间单调于转动方向)。
pub rotor_revolutions: f32,
/// 转子转速(rpm,物理方向)。
pub rotor_speed_rpm: f32,
/// 开环控制量反馈 raw(ESC=转矩电流,GM6020=电流相关量;物理方向)。
pub effort_raw: i16,
/// 电机温度(℃)。
pub temperature_c: i8,
/// 已收到的反馈帧总数;`0` 表示尚未收到任何反馈(上电门控用)。
pub feedback_count: u64,
}
impl MotorState {
/// 是否已收到过反馈(上电后首帧门控)。
#[must_use]
pub fn has_feedback(&self) -> bool {
self.feedback_count > 0
}
}
}
pub mod command {
//! 驱动层命令寄存器(最后写入胜出)。
/// 发给驱动层的电机命令。
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Command {
/// 停用:目标清零,下发 0 输出。
Disabled,
/// 直接下发开环 raw(协议量程,物理方向),绕过闭环。
/// ESC = 电流 raw;GM6020 = 电压 raw。
EffortRaw(i16),
/// 速度环跟踪目标转子转速(rpm,物理方向)。
Velocity(f32),
/// 位置环跟踪目标转子多圈位置(圈,物理方向)。
Position(f32),
}
/// 命令模式标签(不含设定值)——用于切换时复位控制器。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandMode {
/// 停用。
Disabled,
/// 开环 raw。
EffortRaw,
/// 速度环。
Velocity,
/// 位置环。
Position,
}
impl Command {
/// 模式标签。
#[must_use]
pub const fn mode(self) -> CommandMode {
match self {
Self::Disabled => CommandMode::Disabled,
Self::EffortRaw(_) => CommandMode::EffortRaw,
Self::Velocity(_) => CommandMode::Velocity,
Self::Position(_) => CommandMode::Position,
}
}
}
}
pub mod gearbox {
//! 输出轴单位适配器:减速比换算。
//!
//! 把「输出轴单位」的驱动接口适配到「转子单位」的底层电机。
//! `gear_ratio = 转子 : 输出轴`(GM3508 ≈ 19.203)。
use std::time::Duration;
use crate::{Disable, EffortDrive, MotorFeedback, MotorState, PositionDrive, VelocityDrive};
/// 输出轴包装:对外接口使用输出轴单位,对内换算到转子单位。
pub struct OutputShaft<T> {
inner: T,
gear_ratio: f32,
}
impl<T> OutputShaft<T> {
/// 包装底层电机。`gear_ratio` = 转子 : 输出轴(> 0)。
///
/// # Panics
///
/// `gear_ratio` 非有限或 ≤ 0 时 panic。
#[must_use]
pub fn new(inner: T, gear_ratio: f32) -> Self {
assert!(
gear_ratio.is_finite() && gear_ratio > 0.0,
"gear_ratio must be > 0, got {gear_ratio}"
);
Self { inner, gear_ratio }
}
/// 减速比。
#[must_use]
pub fn gear_ratio(&self) -> f32 {
self.gear_ratio
}
/// 底层电机引用。
#[must_use]
pub fn inner(&self) -> &T {
&self.inner
}
/// 转子量 → 输出轴量。
#[must_use]
pub fn to_output(&self, rotor: f32) -> f32 {
rotor / self.gear_ratio
}
/// 输出轴量 → 转子量。
#[must_use]
pub fn to_rotor(&self, output: f32) -> f32 {
output * self.gear_ratio
}
/// 由反馈快照推算输出轴转速(rpm)。
#[must_use]
pub fn output_speed_rpm(&self, state: &MotorState) -> f32 {
state.rotor_speed_rpm / self.gear_ratio
}
}
impl<T: MotorFeedback> MotorFeedback for OutputShaft<T> {
fn id(&self) -> u8 {
self.inner.id()
}
fn state(&self) -> MotorState {
// 只透传转子量;不伪造输出轴字段。
self.inner.state()
}
fn feedback_age(&self) -> Option<Duration> {
self.inner.feedback_age()
}
}
impl<T: VelocityDrive> VelocityDrive for OutputShaft<T> {
/// `output_rpm` 为**输出轴**转速。
fn set_velocity(&self, output_rpm: f32) {
self.inner.set_velocity(self.to_rotor(output_rpm));
}
}
impl<T: EffortDrive> EffortDrive for OutputShaft<T> {
fn set_effort_raw(&self, raw: i16) {
self.inner.set_effort_raw(raw);
}
}
impl<T: PositionDrive> PositionDrive for OutputShaft<T> {
/// `output_rev` 为**输出轴**圈数。
fn set_position(&self, output_rev: f32) {
self.inner.set_position(self.to_rotor(output_rev));
}
}
impl<T: Disable> Disable for OutputShaft<T> {
fn disable(&self) {
self.inner.disable();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Command;
struct Stub {
cmd: std::sync::Mutex<Command>,
}
impl Stub {
fn new() -> Self {
Self { cmd: std::sync::Mutex::new(Command::Disabled) }
}
}
impl MotorFeedback for Stub {
fn id(&self) -> u8 {
1
}
fn state(&self) -> MotorState {
MotorState::default()
}
fn feedback_age(&self) -> Option<Duration> {
Some(Duration::ZERO)
}
}
impl VelocityDrive for Stub {
fn set_velocity(&self, rotor_rpm: f32) {
*self.cmd.lock().unwrap() = Command::Velocity(rotor_rpm);
}
}
impl EffortDrive for Stub {
fn set_effort_raw(&self, raw: i16) {
*self.cmd.lock().unwrap() = Command::EffortRaw(raw);
}
}
impl PositionDrive for Stub {
fn set_position(&self, rotor_rev: f32) {
*self.cmd.lock().unwrap() = Command::Position(rotor_rev);
}
}
impl Disable for Stub {
fn disable(&self) {
*self.cmd.lock().unwrap() = Command::Disabled;
}
}
#[test]
fn velocity_scales_by_gear() {
let shaft = OutputShaft::new(Stub::new(), 19.203);
shaft.set_velocity(100.0);
match *shaft.inner().cmd.lock().unwrap() {
Command::Velocity(rpm) => assert!((rpm - 1920.3).abs() < 0.1, "{rpm}"),
ref c => panic!("unexpected {c:?}"),
}
}
#[test]
fn position_scales_by_gear() {
let shaft = OutputShaft::new(Stub::new(), 36.0);
shaft.set_position(2.0);
match *shaft.inner().cmd.lock().unwrap() {
Command::Position(rev) => assert!((rev - 72.0).abs() < 1e-3, "{rev}"),
ref c => panic!("unexpected {c:?}"),
}
}
#[test]
fn effort_passes_through() {
let shaft = OutputShaft::new(Stub::new(), 10.0);
shaft.set_effort_raw(-1234);
assert_eq!(*shaft.inner().cmd.lock().unwrap(), Command::EffortRaw(-1234));
}
#[test]
#[should_panic = "gear_ratio"]
fn rejects_nonpositive_gear() {
let _ = OutputShaft::new(Stub::new(), 0.0);
}
}
}
pub mod heading {
//! 航向观测源(IMU / 磁力计 / 视觉等)——云台绝对角融合 seam。
//!
//! 电机抽象只保证 [`crate::MotorState::rotor_revolutions`] 连续(上电相对)。
//! 场地绝对角由上层(未来 `gimbal` crate)融合 IMU 与电机多圈得到。
use std::future::Future;
/// 绝对航向观测源。
pub trait HeadingSource: Send {
/// 机体航向角(rad,连续;与 chassis ISO 约定一致:x 前、逆时针为正)。
fn heading_rad(&self) -> impl Future<Output = f32> + Send;
}
/// 云台目标角的参考系。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeadingFrame {
/// 电机转子多圈(上电相对)。
RotorMultiTurn,
/// 场地绝对角(经 [`HeadingSource`] 对齐到转子基准后再下发)。
AbsoluteField,
}
}
pub mod registry {
//! 电机注册表:**按总线 + 反馈帧 ID** 找回设备(ARCHITECTURE.md §5.4)。
//!
//! # 为什么需要它
//!
//! 单总线底盘上,`EscBus` 自己就能把反馈帧分发给对应的 `EscMotor`。但整机是
//! **双 CAN**(§2.1:底盘 `can0` + 功能总线 `can1`),云台 / 拨弹的帧与底盘的
//! 帧可能落在**相同的 ID** 上(GM6020 与 C620 都可能是 0x2xx)。此时"这帧是谁的"
//! 只能由 `(总线, 帧 ID)` 共同决定。
//!
//! [`MotorRegistry`] 就是这张映射表:注册时校验冲突,分发时 O(1) 命中。
//! 当前 `chassisd` 的单总线底盘路径不经过它;它是云台 / 拨弹接入功能总线时的
//! seam(先有接口与测试,避免将来把分发逻辑硬编码进每个任务)。
use can::CanFrame;
use motor_protocol::feedback_frame_id;
/// 物理总线标识(ARCHITECTURE.md §2.1 的双 CAN 拓扑)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum BusId {
/// 底盘总线(C620/C610 驱动轮)。
Chassis,
/// 功能总线(云台 GM6020 + 拨弹 M2006/C610)。
Function,
}
impl BusId {
/// 文本名。
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Chassis => "chassis",
Self::Function => "function",
}
}
}
/// 注册表条目。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegisteredMotor {
/// 所属总线。
pub bus: BusId,
/// 反馈帧 ID。
pub frame_id: u32,
/// 轮名 / 设备名(配置里的 `name`)。
pub name: String,
}
/// 注册错误。
///
/// 手写 `Display` / `Error` 而不是 `derive(thiserror::Error)`:注册表属于
/// **总是可用**的设备抽象(关掉 `bus` feature 也要能编译),不应为一个派生宏
/// 引入可选依赖。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryError {
/// 同一 `(总线, 帧 ID)` 被注册两次——几乎总是接线或配置写重了。
Duplicate {
/// 总线。
bus: &'static str,
/// 冲突的帧 ID。
frame_id: u32,
/// 先注册的名字。
existing: String,
/// 后注册的名字。
name: String,
},
/// 电调 ID 越界(1–8)。
BadEscId(u8),
}
impl std::fmt::Display for RegistryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Duplicate { bus, frame_id, existing, name } => write!(
f,
"总线 {bus} 上的帧 ID 0x{frame_id:03X} 已被 '{existing}' 占用,无法再注册 '{name}'"
),
Self::BadEscId(id) => write!(f, "电调 ID {id} 越界(合法范围 1–8)"),
}
}
}
impl std::error::Error for RegistryError {}
/// 设备注册表。
#[derive(Debug, Default, Clone)]
pub struct MotorRegistry {
entries: Vec<RegisteredMotor>,
}
impl MotorRegistry {
/// 空表。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 按电调 ID 注册(反馈帧 ID = `0x200 + 电调ID`,C620/C610 约定)。
///
/// # Errors
///
/// 电调 ID 越界或该帧 ID 已被占用时返回错误。
pub fn register_esc(
&mut self,
bus: BusId,
esc_id: u8,
name: impl Into<String>,
) -> Result<(), RegistryError> {
let frame_id = feedback_frame_id(esc_id).ok_or(RegistryError::BadEscId(esc_id))?;
self.register_frame(bus, frame_id, name)
}
/// 按反馈帧 ID 注册(GM6020 或非 1–8 的 ID 用它)。
///
/// # Errors
///
/// 该 `(总线, 帧 ID)` 已被占用时返回错误。
pub fn register_frame(
&mut self,
bus: BusId,
frame_id: u32,
name: impl Into<String>,
) -> Result<(), RegistryError> {
let name = name.into();
if let Some(existing) = self.resolve(bus, frame_id) {
return Err(RegistryError::Duplicate {
bus: bus.name(),
frame_id,
existing: existing.name.clone(),
name,
});
}
self.entries.push(RegisteredMotor { bus, frame_id, name });
Ok(())
}
/// 按 `(总线, 帧 ID)` 查找。
#[must_use]
pub fn resolve(&self, bus: BusId, frame_id: u32) -> Option<&RegisteredMotor> {
self.entries.iter().find(|entry| entry.bus == bus && entry.frame_id == frame_id)
}
/// 从一帧反馈找出它的归属设备(ARCHITECTURE.md §5.4 的 `update_from_frame`)。
///
/// 返回 `None` 表示这帧不属于任何已注册设备(例如 IMU 帧,或没配的电机)。
#[must_use]
pub fn dispatch(&self, bus: BusId, frame: &CanFrame) -> Option<&RegisteredMotor> {
self.resolve(bus, frame.id)
}
/// 已注册设备数。
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
/// 是否为空。
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// 全部条目(按注册顺序)。
#[must_use]
pub fn entries(&self) -> &[RegisteredMotor] {
&self.entries
}
/// 某条总线上的设备名。
#[must_use]
pub fn names_on(&self, bus: BusId) -> Vec<&str> {
self.entries
.iter()
.filter(|entry| entry.bus == bus)
.map(|entry| entry.name.as_str())
.collect()
}
}
/// 底盘四轮的注册顺序(与 `mecanum` 的 FL/FR/RL/RR 一致)。
pub const CHASSIS_WHEEL_NAMES: [&str; 4] =
["front_left", "front_right", "rear_left", "rear_right"];
/// 把底盘四轮按顺序注册进表(电调 ID 从 1 开始连续)。
///
/// # Errors
///
/// 任一电调 ID 越界或冲突时返回错误。
pub fn register_chassis_wheels(
registry: &mut MotorRegistry,
esc_ids: [u8; 4],
) -> Result<(), RegistryError> {
for (name, esc_id) in CHASSIS_WHEEL_NAMES.iter().zip(esc_ids) {
registry.register_esc(BusId::Chassis, esc_id, *name)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use motor_protocol::{MAX_ESC_ID, MIN_ESC_ID};
#[test]
fn registers_and_dispatches_chassis_wheels() {
let mut registry = MotorRegistry::new();
register_chassis_wheels(&mut registry, [1, 2, 3, 4]).unwrap();
assert_eq!(registry.len(), 4);
assert_eq!(registry.names_on(BusId::Chassis).len(), 4);
assert!(registry.names_on(BusId::Function).is_empty());
let frame = CanFrame::new(0x203, [0; 8]);
let found = registry.dispatch(BusId::Chassis, &frame).unwrap();
assert_eq!(found.name, "rear_left", "0x203 = 电调 3 = 后左");
}
#[test]
fn same_frame_id_on_another_bus_is_not_a_conflict() {
let mut registry = MotorRegistry::new();
registry.register_esc(BusId::Chassis, 1, "front_left").unwrap();
// 功能总线上的 GM6020 ID 1 反馈同样是 0x205 —— 不同总线,互不冲突。
registry.register_frame(BusId::Function, 0x205, "gimbal_yaw").unwrap();
assert_eq!(registry.len(), 2);
assert_eq!(registry.resolve(BusId::Chassis, 0x205), None);
assert_eq!(registry.resolve(BusId::Function, 0x205).unwrap().name, "gimbal_yaw");
}
#[test]
fn duplicates_on_the_same_bus_are_rejected_with_both_names() {
let mut registry = MotorRegistry::new();
registry.register_esc(BusId::Chassis, 2, "front_right").unwrap();
let error = registry.register_esc(BusId::Chassis, 2, "typo_front_right").unwrap_err();
let text = error.to_string();
assert!(text.contains("front_right"), "{text}");
assert!(text.contains("typo_front_right"), "{text}");
assert_eq!(registry.len(), 1, "失败的注册不得写入");
}
#[test]
fn out_of_range_esc_ids_are_rejected() {
let mut registry = MotorRegistry::new();
assert_eq!(
registry.register_esc(BusId::Chassis, 0, "ghost"),
Err(RegistryError::BadEscId(0))
);
assert_eq!(
registry.register_esc(BusId::Chassis, 9, "ghost"),
Err(RegistryError::BadEscId(9))
);
assert!(registry.is_empty());
assert!(feedback_frame_id(MIN_ESC_ID).is_some());
assert!(feedback_frame_id(MAX_ESC_ID).is_some());
}
#[test]
fn unknown_frames_dispatch_to_nothing() {
let mut registry = MotorRegistry::new();
register_chassis_wheels(&mut registry, [1, 2, 3, 4]).unwrap();
// DETA10 的 IMU 帧不是任何电机。
let imu = CanFrame::new(0x100, [0; 8]);
assert!(registry.dispatch(BusId::Chassis, &imu).is_none());
}
}
}
pub mod testing {
//! 测试替身:内存电机与航向源。
use std::future::Future;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use crate::{
Command, Disable, EffortDrive, HeadingSource, MotorFeedback, MotorState, PositionDrive,
VelocityDrive,
};
/// 锁辅助:中毒锁直接恢复内部数据。
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// 内存电机:记录命令、可注入状态。供上层单测,无需总线。
///
/// 句柄可克隆(内部 `Arc` 共享),便于一边注入/断言一边把所有权交给控制器。
#[derive(Clone)]
pub struct MockMotor {
id: u8,
published: Arc<Mutex<(MotorState, Command)>>,
last_feedback: Arc<Mutex<Option<Instant>>>,
}
impl MockMotor {
/// 创建指定 ID 的空电机。
#[must_use]
pub fn new(id: u8) -> Self {
Self {
id,
published: Arc::new(Mutex::new((MotorState::default(), Command::Disabled))),
last_feedback: Arc::new(Mutex::new(None)),
}
}
/// 注入反馈快照(记为「刚刚收到」)。
pub fn inject_state(&self, state: MotorState) {
self.inject_state_with_age(state, Duration::ZERO);
}
/// 注入反馈快照并指定其“已过去”的时长(用于在线/超时测试)。
pub fn inject_state_with_age(&self, state: MotorState, age: Duration) {
lock(&self.published).0 = state;
let at = Instant::now().checked_sub(age);
*lock(&self.last_feedback) = Some(at.unwrap_or_else(Instant::now));
}
/// 最近一次命令。
#[must_use]
pub fn last_command(&self) -> Command {
lock(&self.published).1
}
}
impl MotorFeedback for MockMotor {
fn id(&self) -> u8 {
self.id
}
fn state(&self) -> MotorState {
lock(&self.published).0
}
fn feedback_age(&self) -> Option<Duration> {
let at = *lock(&self.last_feedback);
Some(at?.elapsed())
}
}
impl VelocityDrive for MockMotor {
fn set_velocity(&self, rotor_rpm: f32) {
lock(&self.published).1 = Command::Velocity(rotor_rpm);
}
}
impl EffortDrive for MockMotor {
fn set_effort_raw(&self, raw: i16) {
lock(&self.published).1 = Command::EffortRaw(raw);
}
}
impl PositionDrive for MockMotor {
fn set_position(&self, rotor_rev: f32) {
lock(&self.published).1 = Command::Position(rotor_rev);
}
}
impl Disable for MockMotor {
fn disable(&self) {
lock(&self.published).1 = Command::Disabled;
}
}
/// 恒定航向测试源。
pub struct MockHeading {
rad: f32,
}
impl MockHeading {
/// 创建固定航向源。
#[must_use]
pub const fn new(rad: f32) -> Self {
Self { rad }
}
}
impl HeadingSource for MockHeading {
fn heading_rad(&self) -> impl Future<Output = f32> + Send {
std::future::ready(self.rad)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mock_motor_records_commands() {
let m = MockMotor::new(3);
m.set_velocity(1000.0);
assert_eq!(m.last_command(), Command::Velocity(1000.0));
m.disable();
assert_eq!(m.last_command(), Command::Disabled);
}
#[test]
fn mock_motor_injects_state() {
let m = MockMotor::new(1);
assert!(!m.state().has_feedback());
assert!(m.feedback_age().is_none());
m.inject_state(MotorState { feedback_count: 5, ..MotorState::default() });
assert!(m.state().has_feedback());
assert!(m.feedback_age().is_some_and(|age| age < Duration::from_millis(50)));
}
#[test]
fn mock_motor_feedback_age_can_be_stale() {
let m = MockMotor::new(1);
m.inject_state_with_age(
MotorState { feedback_count: 1, ..MotorState::default() },
Duration::from_millis(250),
);
assert!(m.feedback_age().is_some_and(|age| age >= Duration::from_millis(200)));
}
}
}
pub mod motor_common {
//! 电机种类:把"型号"变成可在设备层与配置层之间传递的事实。
//!
//! 设备层不关心电调怎么接、齿轮多大,但必须知道**协议族**:电流环在电调内部,
//! 而不同协议族的控制字含义不同(电流 vs 电压)。[`MotorKind`] 就是这张表。
use motor_protocol::EscModel;
/// 支持的电机型号。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MotorKind {
/// GM3508 + C620(底盘驱动轮)。
Gm3508,
/// M2006 + C610(拨弹 / 小负载)。
M2006,
/// GM6020(云台直驱,**电压**控制)。
Gm6020,
}
impl MotorKind {
/// 配套电调型号。
#[must_use]
pub const fn esc_model(self) -> EscModel {
match self {
// GM6020 的反馈标度与 C620 相同,但控制字是电压。
Self::Gm3508 | Self::Gm6020 => EscModel::C620,
Self::M2006 => EscModel::C610,
}
}
/// 控制量是否是**电压**(而非电流)。
///
/// 总线驱动据此选择组帧方式:C620/C610 走 `encode_current_frame`,
/// GM6020 走 `motor_protocol::motor::gm6020::encode_voltage_frame`。
#[must_use]
pub const fn encodes_voltage(self) -> bool {
matches!(self, Self::Gm6020)
}
/// 该型号是否与其它型号**共用**电流控制帧(C620/C610 共用 `0x200`/`0x1FF`)。
#[must_use]
pub const fn shares_current_frames(self) -> bool {
!self.encodes_voltage()
}
/// 默认减速比(转子 : 输出轴)。接硬件前必须按实车核对。
#[must_use]
pub const fn default_gear_ratio(self) -> f32 {
match self {
Self::Gm3508 => 3591.0 / 187.0,
// M2006 常见配置 36:1;与 ARCHITECTURE.md §8.2 的 motors.toml 一致。
Self::M2006 => 36.0,
// 云台直驱。
Self::Gm6020 => 1.0,
}
}
/// 型号名(配置与日志用)。
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Gm3508 => "GM3508",
Self::M2006 => "M2006",
Self::Gm6020 => "GM6020",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_gm6020_encodes_voltage() {
assert!(MotorKind::Gm6020.encodes_voltage());
assert!(!MotorKind::Gm3508.encodes_voltage());
assert!(!MotorKind::M2006.encodes_voltage());
assert!(MotorKind::Gm3508.shares_current_frames());
assert!(!MotorKind::Gm6020.shares_current_frames());
}
#[test]
fn esc_models_match_the_scale_documentation() {
assert_eq!(MotorKind::Gm3508.esc_model(), EscModel::C620);
assert_eq!(MotorKind::Gm6020.esc_model(), EscModel::C620);
assert_eq!(MotorKind::M2006.esc_model(), EscModel::C610);
assert_eq!(MotorKind::M2006.esc_model().max_current_raw(), 10_000);
}
#[test]
fn default_gear_ratios_are_sane() {
assert!((MotorKind::Gm3508.default_gear_ratio() - 19.203).abs() < 5e-3);
assert!((MotorKind::Gm6020.default_gear_ratio() - 1.0).abs() < 1e-6);
assert!(MotorKind::M2006.default_gear_ratio() > 0.0);
assert_eq!(MotorKind::Gm6020.name(), "GM6020");
}
}
}
pub mod motor_gm6020 {
//! GM6020 设备(云台直驱,**电压**控制)。
//!
//! # 当前状态(务必读)
//!
//! 协议层的电压帧编解码已经可用([`motor_protocol::gm6020`]),但**总线驱动
//! `EscBus` 目前只实现 C620/C610 的电流路径**:它把 `set_effort_raw` 直接组
//! 成电流帧。把 GM6020 接上云台还需要总线驱动增加"电压帧"分支
//! ([`MotorKind::encodes_voltage`] 就是给那条分支准备的判据),这属于云台
//! 部分的工作(ARCHITECTURE.md §5.7 `gimbal` 任务)。
//!
//! 在此之前,本模块提供的是**型号事实**(协议族、减速比、ID 映射),
//! 不含"已经能驱动 GM6020"的暗示。
use crate::MotorKind;
/// 该型号的 [`MotorKind`]。
pub const KIND: MotorKind = MotorKind::Gm6020;
/// 云台直驱的减速比。
pub const GEAR_RATIO: f32 = 1.0;
/// 该型号的控制量是电压而非电流。
pub const ENCODES_VOLTAGE: bool = true;
/// 总线驱动是否已支持该型号的驱动路径。
///
/// 现在返回 `false`:电压帧分支随云台工作落地。用它做**运行期**判定,
/// 而不是让配置静默接受一个驱动不了的型号。
#[must_use]
pub const fn drive_path_available() -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
use motor_protocol::gm6020::{COMMAND_FRAME_ID_PRIMARY, feedback_frame_id};
#[test]
fn kind_facts_match_the_protocol_module() {
assert_eq!(KIND, MotorKind::Gm6020);
assert!(KIND.encodes_voltage());
assert!((KIND.default_gear_ratio() - GEAR_RATIO).abs() < 1e-6);
// 两个常量同源:`ENCODES_VOLTAGE` 就是 `KIND.encodes_voltage()` 的镜像。
assert_eq!(ENCODES_VOLTAGE, KIND.encodes_voltage());
}
#[test]
fn gm6020_uses_its_own_frame_ids() {
assert_eq!(COMMAND_FRAME_ID_PRIMARY, 0x1FF);
assert_eq!(feedback_frame_id(1), Some(0x205));
// 与 C620 不同:C620 的 ID 1 控制帧是 0x200。
assert_ne!(COMMAND_FRAME_ID_PRIMARY, motor_protocol::COMMAND_FRAME_ID_PRIMARY);
}
#[test]
fn drive_path_is_honestly_reported_as_unavailable() {
assert!(!drive_path_available(), "云台电压路径落地后本断言才应改为 true");
}
}
}
pub use command::*;
pub use gearbox::*;
pub use heading::*;
pub use motor_common::MotorKind;
pub use registry::*;
pub use state::*;
pub use testing::*;
pub use traits::*;
@@ -1,12 +1,16 @@
[package] [package]
name = "motor-core" name = "pid"
description = "PID controller"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
description = "Protocol-agnostic motor abstraction: Motor trait, feedback state, and PID controllers"
[lints] [lints]
workspace = true workspace = true
[dependencies] [dependencies]
[dev-dependencies]
approx = { workspace = true }
@@ -1,3 +1,5 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! 通用 PID 控制器。 //! 通用 PID 控制器。
/// 并联式 PID 控制器(f32,显式时间步)。 /// 并联式 PID 控制器(f32,显式时间步)。
@@ -102,6 +104,12 @@ impl Pid {
self.last_output = 0.0; self.last_output = 0.0;
} }
/// 上一次 `update` 的输出(诊断 / 串级外环取速度目标用)。
#[must_use]
pub fn last_output(&self) -> f32 {
self.last_output
}
/// 当前积分累加项。 /// 当前积分累加项。
#[must_use] #[must_use]
pub fn integral(&self) -> f32 { pub fn integral(&self) -> f32 {
@@ -1,6 +1,6 @@
[package] [package]
name = "dji-esc-sim" name = "sim-harness"
description = "C620/C610 plant simulation: friction/delay model, scenario harness, and async plant rig" description = "Closed-loop motor simulation harness and fake ESC rig"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
@@ -10,9 +10,12 @@ license.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
can-link = { workspace = true } can = { workspace = true }
motor-core = { workspace = true } motor = { workspace = true }
dji-esc = { workspace = true } motor-protocol = { workspace = true }
motor-control = { workspace = true }
motor-model = { workspace = true }
experiment = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
@@ -20,3 +23,4 @@ rand = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["test-util"] } tokio = { workspace = true, features = ["test-util"] }
approx = { workspace = true }
File diff suppressed because it is too large Load Diff
@@ -1,20 +1,21 @@
[package] [package]
name = "dji-esc" name = "slcan"
description = "SLCAN serial transport bridge"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
description = "DJI RoboMaster ESC (C620/C610) CAN protocol: codec, multi-turn tracking, and async bus driver"
[lints] [lints]
workspace = true workspace = true
[dependencies] [dependencies]
motor-core = { workspace = true } can = { workspace = true }
can-link = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
slcan-proto = { workspace = true }
serial = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["test-util"] } tokio = { workspace = true }
@@ -1,8 +1,8 @@
//! SLCAN 桥接:把串口 slcan 设备(如 CANable2 的 `/dev/ttyACM0`)接入 [`can_link::CanLink`]。 //! SLCAN 桥接:把串口 slcan 设备(如 CANable2 的 `/dev/ttyACM0`)接入 [`can::CanLink`]。
//! //!
//! 基于 [`slcan`] crate(Lawicel ASCII 协议):`S8` = 1 Mbps(C620 标准),`O` 打开通道。 //! 基于 [`slcan`] crate(Lawicel ASCII 协议):`S8` = 1 Mbps(C620 标准),`O` 打开通道。
//! 阻塞串口 I/O 跑在专用线程上,与 SocketCAN 桥一样只暴露通道端点——对本 crate //! 阻塞串口 I/O 跑在专用线程上,与 SocketCAN 桥一样只暴露通道端点——对本 crate
//! 的调用方而言,`dji-esc` 看到的是一个普通 [`CanLink`],与传输介质无关。 //! 的调用方(`motor / esc-bus`)而言,看到的是一个普通 [`CanLink`],与传输介质无关。
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
use std::io; use std::io;
@@ -11,9 +11,10 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use can_link::{CanFrame as LinkFrame, CanLink}; use can::{CanFrame as LinkFrame, CanLink};
use serial::prelude::*; use serial::prelude::*;
use slcan::{BitRate, CanSocket}; use slcan::{BitRate, CanSocket};
use slcan_proto as slcan;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "socketcan"
description = "SocketCAN transport bridge"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
can = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
esc-bus = { workspace = true }
motor = { workspace = true }
motor-control = { workspace = true }
tokio = { workspace = true }
motor-protocol = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
socketcan-rs = { workspace = true }
+122
View File
@@ -0,0 +1,122 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
//! 真实总线桥接实现(Linux)。
#[cfg(target_os = "linux")]
mod imp {
use std::io;
use std::sync::Arc;
use can::{CanFrame as LinkFrame, CanLink};
use socketcan_rs::{
CanDataFrame, CanFilter, CanFrame as ScFrame, EmbeddedFrame as _, Frame as _,
SocketOptions as _, StandardId, tokio::CanSocket,
};
use tokio::sync::mpsc;
use tracing::{debug, error};
/// 单个链路的通道缓冲(1kHz × 8 电调反馈余量)。
const CHANNEL_BOUND: usize = 512;
/// 打开 CAN 接口并桥接为 [`CanLink`],返回面向总线驱动的端点。
///
/// 内部:
/// - 硬件过滤只放行电调反馈 ID 段(`0x200..=0x20F`,覆盖 0x201–0x208,
/// 非反馈帧再由协议层解码拒绝,双保险);
/// - 两个桥接任务:socket → 通道(反馈)、通道 → socket(命令)。
///
/// # Errors
///
/// 接口不存在 / 权限不足等 `io::Error`。
pub fn open_link(interface: &str) -> io::Result<CanLink> {
let socket = CanSocket::open(interface)?;
socket.set_filters(&[CanFilter::new(0x200, 0x7F0)])?;
let socket = Arc::new(socket);
let (bus_side, bridge_side) = CanLink::pair(CHANNEL_BOUND);
let CanLink { outgoing_tx, incoming_rx } = bridge_side;
tokio::spawn(rx_bridge(Arc::clone(&socket), outgoing_tx));
tokio::spawn(tx_bridge(socket, incoming_rx));
Ok(bus_side)
}
/// socket → 通道:真实总线反馈进入协议 bus。
async fn rx_bridge(socket: Arc<CanSocket>, tx: mpsc::Sender<LinkFrame>) {
loop {
match socket.read_frame().await {
Ok(frame) => {
if let Some(frame) = to_link_frame(&frame) {
if tx.send(frame).await.is_err() {
debug!("bus side closed, rx bridge exiting");
return;
}
}
}
Err(e) => {
// 接口脱落(如 USB 转 CAN 拔出)等致命错误:退出守护进程,
// 由 systemd 拉起重连。
error!(error = %e, "CAN read failed, rx bridge exiting");
return;
}
}
}
}
/// 通道 → socket:协议 bus 命令写入真实总线。
async fn tx_bridge(socket: Arc<CanSocket>, mut rx: mpsc::Receiver<LinkFrame>) {
while let Some(frame) = rx.recv().await {
match to_socketcan_frame(&frame) {
Some(sc_frame) => {
if let Err(e) = socket.write_frame(sc_frame).await {
error!(error = %e, "CAN write failed, tx bridge exiting");
return;
}
}
None => {
debug!(id = frame.id, "drop frame with non-standard CAN id");
}
}
}
debug!("bus side closed, tx bridge exiting");
}
/// socketcan 帧 → 链路帧(仅 8 字节数据帧)。
fn to_link_frame(frame: &ScFrame) -> Option<LinkFrame> {
match frame {
ScFrame::Data(df) if df.data().len() == 8 => {
Some(LinkFrame::new(df.raw_id(), df.data().try_into().ok()?))
}
_ => None,
}
}
/// 链路帧 → socketcan 帧(ID 必须落在 11 位标准 ID 范围内)。
fn to_socketcan_frame(frame: &LinkFrame) -> Option<ScFrame> {
let id = StandardId::new(frame.id as u16)?;
let df = CanDataFrame::new(id, &frame.data)?;
Some(ScFrame::Data(df))
}
}
#[cfg(target_os = "linux")]
pub use imp::*;
#[cfg(not(target_os = "linux"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Unsupported;
#[cfg(not(target_os = "linux"))]
impl std::fmt::Display for Unsupported {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SocketCAN is only available on Linux")
}
}
#[cfg(not(target_os = "linux"))]
impl std::error::Error for Unsupported {}
#[cfg(not(target_os = "linux"))]
pub fn open_link(_interface: &str) -> Result<can::CanLink, Unsupported> {
Err(Unsupported)
}
@@ -7,10 +7,11 @@
use std::time::Duration; use std::time::Duration;
use dji_esc::{CanFrame, CanLink}; use can::{CanFrame, CanLink};
use motor_core::Motor; use motor::{MotorFeedback, VelocityDrive};
use socketcan::tokio::CanSocket; use motor_control::{MotorPolicy, VelocityGains};
use socketcan::{CanDataFrame, CanFrame as ScFrame, EmbeddedFrame as _, Frame as _, StandardId}; use socketcan_rs::tokio::CanSocket;
use socketcan_rs::{CanDataFrame, CanFrame as ScFrame, EmbeddedFrame as _, Frame as _, StandardId};
use tokio::time::timeout; use tokio::time::timeout;
const IFACE: &str = "vcan0"; const IFACE: &str = "vcan0";
@@ -22,7 +23,7 @@ fn sc_data_frame(id: u16, data: [u8; 8]) -> ScFrame {
#[tokio::test] #[tokio::test]
#[ignore = "requires vcan0 (see `just vcan-test`)"] #[ignore = "requires vcan0 (see `just vcan-test`)"]
async fn bridge_relays_command_to_bus() { async fn bridge_relays_command_to_bus() {
let link = dji_esc_socketcan::open_link(IFACE).unwrap(); let link = socketcan::open_link(IFACE).unwrap();
let observer = CanSocket::open(IFACE).unwrap(); let observer = CanSocket::open(IFACE).unwrap();
let command = CanFrame::new(0x200, [0x04, 0xD2, 0, 0, 0, 0, 0, 0]); let command = CanFrame::new(0x200, [0x04, 0xD2, 0, 0, 0, 0, 0, 0]);
@@ -44,7 +45,7 @@ async fn bridge_relays_command_to_bus() {
#[tokio::test] #[tokio::test]
#[ignore = "requires vcan0 (see `just vcan-test`)"] #[ignore = "requires vcan0 (see `just vcan-test`)"]
async fn bridge_relays_feedback_from_bus() { async fn bridge_relays_feedback_from_bus() {
let CanLink { outgoing_tx: _, mut incoming_rx } = dji_esc_socketcan::open_link(IFACE).unwrap(); let CanLink { outgoing_tx: _, mut incoming_rx } = socketcan::open_link(IFACE).unwrap();
let simulator = CanSocket::open(IFACE).unwrap(); let simulator = CanSocket::open(IFACE).unwrap();
// 电调 1 反馈:角度 8191、-364rpm、电流 123、45℃。 // 电调 1 反馈:角度 8191、-364rpm、电流 123、45℃。
@@ -63,15 +64,14 @@ async fn bridge_relays_feedback_from_bus() {
#[ignore = "requires vcan0 (see `just vcan-test`)"] #[ignore = "requires vcan0 (see `just vcan-test`)"]
async fn esc_bus_end_to_end_over_vcan() { async fn esc_bus_end_to_end_over_vcan() {
// EscBus ↔ 真实内核 CAN 栈 ↔ 模拟电调(第二个 socket)。 // EscBus ↔ 真实内核 CAN 栈 ↔ 模拟电调(第二个 socket)。
let link = dji_esc_socketcan::open_link(IFACE).unwrap(); let link = socketcan::open_link(IFACE).unwrap();
let mut esc = EscSim::new(IFACE, &[1]); let mut esc = EscSim::new(IFACE, &[1]);
let bus = dji_esc::EscBus::spawn( let config = esc_bus::EscConfig::gm3508("m1", 1, 1.0);
link, let mut policy = MotorPolicy::new();
vec![dji_esc::EscConfig::gm3508("m1", 1, 1.0)], policy.configure(1, config.current_limit, VelocityGains::new(8.0, 2.0, 0.0, 16384.0 / 9254.0));
dji_esc::BusOptions::default(), let bus =
) esc_bus::EscBus::spawn(link, vec![config], esc_bus::BusOptions::default(), policy).unwrap();
.unwrap();
bus.motor(1).unwrap().set_velocity(2000.0); bus.motor(1).unwrap().set_velocity(2000.0);
// 仿真 2s:模拟电调按命令回反馈,速度环应收敛。 // 仿真 2s:模拟电调按命令回反馈,速度环应收敛。
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "spin"
description = "Chassis and single-motor hardware bench tool"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
bench = { workspace = true }
motor = { workspace = true }
esc-bus = { workspace = true }
experiment = { workspace = true }
motor-control = { workspace = true }
mecanum = { workspace = true }
slcan = { workspace = true }
socketcan = { workspace = true }
tokio = { workspace = true }
clap = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
+722
View File
@@ -0,0 +1,722 @@
mod chassis {
//! 实车台架工具:让**四个轮子同时**以固定**转子**转速旋转(默认 500 rpm)。
//!
//! ```sh
//! # T153 板载 CANable2(slcan 固件)→ /dev/ttyACM0,四轮 500 rpm 转子,缓升 1s 保持 10s
//! cargo run --release -p chassis-bench --bin chassis-spin -- \
//! --device /dev/ttyACM0 --rpm 500
//!
//! # 只验证链路与反馈,不发任何非零电流(接线排查用)
//! cargo run --release -p chassis-bench --bin chassis-spin -- --device /dev/ttyACM0 --dry-run
//! ```
//!
//! # 抽象边界
//!
//! 本工具是**组合根**:`dji-esc-slcan`(链路桥接)→ `dji-esc`(C620 协议 +
//! 速度环)→ `motor-core`(`VelocityDrive` / `MotorFeedback` / `Disable` 能力
//! trait)。激励场景与安全包络来自同 crate 的 `chassis-bench` 库,与
//! `chassis-tune` 的真机后端**共用同一套实现**。
//!
//! # 命令租约(重要)
//!
//! `EscBus` 的命令是**租约**:`command_timeout`(默认 50ms)内没有重新下发命令,
//! 驱动会强制 0 电流。所以控制循环必须**逐拍重发**同一个设定值——本工具在 1kHz
//! 节拍里重发(与 `chassisd` 的控制循环一致)。只在开头下发一次是**不行的**。
//!
//! # 安全
//!
//! 轮子必须架空或整车固定,急停可及。默认低限流、有限时长、ctrl-c 任意时刻
//! 安全停机(先 0 电流再关总线);未收到全部反馈前绝不下发速度。
use std::process::ExitCode;
use std::time::Duration;
use bench::{SafetyEnvelope, SafetyMonitor};
use clap::{Parser, ValueEnum};
use esc_bus::{BusOptions, EscBus, EscBusHandle, EscConfig, EscMotor};
use experiment::Scenario;
use motor::{Disable, MotorFeedback, VelocityDrive};
use motor_control::{MotorPolicy, VelocityGains};
use slcan::{CanSpeed, SlcanOptions, open_link_with};
use tokio::time::{Instant, MissedTickBehavior, interval, sleep};
/// 稳态判据:平均转子转速与目标的相对误差上限。
const TOLERANCE: f32 = 0.05;
/// 状态打印周期。
const REPORT_PERIOD: Duration = Duration::from_millis(200);
/// 反馈新鲜度阈值:`feedback_age` 超过它即视为该电调掉线。
const FRESH: Duration = Duration::from_millis(100);
/// 过速需要持续这么久才判定为失控并停机(避免单帧噪声误触发)。
const OVERSPEED_GRACE: Duration = Duration::from_millis(60);
/// 控制节拍(C620 期望 1kHz 命令)。
const CONTROL_PERIOD: Duration = Duration::from_millis(1);
/// 四轮默认名(顺序固定 FL、FR、RL、RR)。
const WHEEL_NAMES: [&str; 4] = ["front_left", "front_right", "rear_left", "rear_right"];
#[derive(Debug, Parser)]
#[command(name = "chassis-spin", version, about = "台架:四轮同时旋转(默认 500 rpm 转子)")]
struct Args {
/// slcan 串口设备(T153 板载 CANable2 为 /dev/ttyACM0)。
#[arg(long, default_value = "/dev/ttyACM0")]
device: String,
/// 串口波特率(CDC ACM 一般忽略;CH340 类适配器需与固件一致)。
#[arg(long, default_value_t = 2_000_000)]
serial_baud: u32,
/// 串口超时(毫秒)。读写共用:`serial` 的 `TTYPort::write` 也按它 poll(POLLOUT)。
#[arg(long, default_value_t = 50)]
serial_timeout_ms: u64,
/// CAN 波特率档位(C620 标准 = 1Mbps)。
#[arg(long, value_enum, default_value_t = CanSpeedArg::M1)]
can_speed: CanSpeedArg,
/// 电调 ID 列表,顺序 = FL、FR、RL、RR。
#[arg(long, value_delimiter = ',', default_value = "1,2,3,4")]
esc_ids: Vec<u8>,
/// 各轮安装方向 ±1(默认左右镜像 → 四轮物理同向)。
#[arg(
long,
value_delimiter = ',',
allow_hyphen_values = true,
default_value = "1,-1,1,-1"
)]
directions: Vec<f32>,
/// 目标**转子**转速(rpm);轮端 = 转子 ÷ 减速比。
#[arg(long, default_value_t = 500.0)]
rpm: f32,
/// 减速比(转子 : 输出轴,GM3508 ≈ 19.203)。
#[arg(long, default_value_t = 19.203)]
gear_ratio: f32,
/// 电流限幅(raw,C620 满量程 16384)。
///
/// 默认 1500 raw(≈1.8A):要越过静摩擦(实测约 400~600 raw)且留出整定余量。
/// 上限同时也是 PID 抗饱和的钳位点——**必须贴近实际所需电流**,
/// 否则积分会在堵转期间积累,一旦起动就冲过头。
#[arg(long, default_value_t = 1500)]
current_limit: i16,
/// 缓升时间:0 → 目标转速(毫秒)。
///
/// 默认 3000:空载 GM3508 的静摩擦需要约 400~600 raw 才能起动(stick-slip),
/// 缓升让积分在起动前平滑建立,避免"先憋住、一动就飞"。
#[arg(long, default_value_t = 3000)]
ramp_ms: u64,
/// 目标转速保持时长(秒)。
#[arg(long, default_value_t = 10.0)]
hold_secs: f32,
/// 等待全部电调反馈的超时(毫秒)。
#[arg(long, default_value_t = 3000)]
startup_timeout_ms: u64,
/// 只验证链路与反馈,不发出任何非零电流。
#[arg(long)]
dry_run: bool,
/// 允许缺失部分电调:仅驱动有反馈的电调继续测试(默认要求四个电调全部在线)。
#[arg(long)]
allow_partial: bool,
/// 速度环 kp(raw/rpm)。
///
/// 空载台架默认 2.0:被控对象近似积分器(≈12 rpm/s per raw),
/// 高 kp(如 8~18)会因反馈延迟震荡;低 kp 配合大 ki 才稳。
#[arg(long, default_value_t = 2.0)]
kp: f32,
/// 速度环 ki(raw/(rpm·s))。
///
/// 空载台架默认 10:静摩擦需要积分项缓慢"顶"到起动电流,ki 太小会长期
/// 停在 300~400 rpm 达不到目标;抗饱和由 `current_limit` 钳位兜底,
/// 所以大 ki 不会跑飞。
#[arg(long, default_value_t = 10.0)]
ki: f32,
/// 速度环 kd。
#[arg(long, default_value_t = 0.0)]
kd: f32,
/// 速度前馈(raw/rpm)。
///
/// **空载台架默认 0**:轮子架空时维持转速只需要克服摩擦的极小电流,
/// 任何接近 1/K(GM3508 空载 K ≈ 0.57 rpm/raw → 1/K ≈ 1.77)的前馈都会把
/// 空载轮子直接推到最高转速。只有带载工况才需要正前馈。
#[arg(long, default_value_t = 0.0)]
feedforward: f32,
/// 过速保护上限(rpm):任一参与测试的电调超过它即立即停机。
/// 默认 = 目标 × 2 + 200。
#[arg(long)]
max_rpm: Option<f32>,
}
/// CAN 波特率档位(对应 slcan 的 `Sn` 命令)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CanSpeedArg {
/// `S8`:1 Mbps。
#[value(name = "1m")]
M1,
/// `S6`:500 kbps。
#[value(name = "500k")]
K500,
}
impl From<CanSpeedArg> for CanSpeed {
fn from(value: CanSpeedArg) -> Self {
match value {
CanSpeedArg::M1 => Self::M1,
CanSpeedArg::K500 => Self::K500,
}
}
}
impl Args {
/// 过速保护上限:显式 `--max-rpm` 优先,否则目标 × 2 + 200。
fn overspeed_limit(&self) -> f32 {
self.max_rpm.unwrap_or(self.rpm * 2.0 + 200.0)
}
/// 参数自检:越界参数在碰硬件之前就拒绝。
fn validate(&self) -> Result<(), String> {
if self.esc_ids.is_empty() {
return Err("--esc-ids 不能为空".into());
}
if self.esc_ids.len() != self.directions.len() {
return Err(format!(
"--esc-ids({} 个)与 --directions({} 个)数量必须一致",
self.esc_ids.len(),
self.directions.len()
));
}
for &id in &self.esc_ids {
if !(1..=8).contains(&id) {
return Err(format!("电调 ID {id} 超出 1..=8"));
}
}
for &direction in &self.directions {
let bits = direction.to_bits();
if bits != 1.0f32.to_bits() && bits != (-1.0f32).to_bits() {
return Err(format!("方向必须是 +1 或 -1,得到 {direction}"));
}
}
if !(self.rpm.is_finite() && self.rpm >= 0.0) {
return Err(format!("--rpm 必须 ≥ 0,得到 {}", self.rpm));
}
if let Some(max) = self.max_rpm
&& !(max.is_finite() && max > self.rpm)
{
return Err(format!("--max-rpm 必须大于目标转速 {},得到 {max}", self.rpm));
}
if !(self.gear_ratio.is_finite() && self.gear_ratio > 0.0) {
return Err(format!("--gear-ratio 必须 > 0,得到 {}", self.gear_ratio));
}
if !(1..=16384).contains(&self.current_limit) {
return Err(format!(
"--current-limit 必须在 1..=16384,得到 {}",
self.current_limit
));
}
if self.hold_secs < 0.0 {
return Err(format!("--hold-secs 不能为负,得到 {}", self.hold_secs));
}
Ok(())
}
/// 四个电调配置(方向、减速比、电流限幅、PID 全部显式落地)。
fn esc_configs(&self) -> Vec<EscConfig> {
self.esc_ids
.iter()
.enumerate()
.map(|(index, &id)| {
let name = WHEEL_NAMES
.get(index)
.map_or_else(|| format!("wheel{id}"), |name| (*name).to_owned());
let mut cfg = EscConfig::gm3508(name, id, self.directions[index]);
cfg.gear_ratio = self.gear_ratio;
cfg.current_limit = self.current_limit;
cfg
})
.collect()
}
fn control_policy(&self) -> MotorPolicy {
let mut policy = MotorPolicy::new();
let gains = VelocityGains::new(self.kp, self.ki, self.kd, self.feedforward);
for id in &self.esc_ids {
policy.configure(*id, self.current_limit, gains);
}
policy
}
}
fn bus_options() -> BusOptions {
BusOptions {
period: CONTROL_PERIOD,
feedback_timeout: Duration::from_millis(100),
// 命令租约 50ms;控制循环每 1ms 续租。
command_timeout: Duration::from_millis(50),
}
}
pub async fn main_with_args(parameters: Vec<String>) -> ExitCode {
init_tracing();
let args =
Args::parse_from(std::iter::once(String::from("spin-chassis")).chain(parameters));
if let Err(problem) = args.validate() {
eprintln!("✗ 参数错误:{problem}");
return ExitCode::FAILURE;
}
print_banner(&args);
match run(&args).await {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::FAILURE,
Err(e) => {
eprintln!("✗ 运行失败:{e}");
ExitCode::FAILURE
}
}
}
/// 初始化日志:链路桥的读写失败/静默告警必须可见,
/// 否则现场只剩一句"没有反馈",无法区分适配器故障与整车接线问题。
fn init_tracing() {
let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("warn,slcan=debug,socketcan=debug")
});
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
}
fn print_banner(args: &Args) {
println!("=== 台架:四轮同时旋转(转子目标 {:.1} rpm)===", args.rpm);
println!(
"设备 {} | CAN {:?} | 电调 {:?} | 方向 {:?} | 减速比 {:.3}",
args.device, args.can_speed, args.esc_ids, args.directions, args.gear_ratio
);
println!(
"转子 {:.1} rpm ÷ {:.3} = 轮端 {:.2} rpm | 电流限幅 {} raw | 缓升 {}ms | 保持 {:.1}s{}",
args.rpm,
args.gear_ratio,
args.rpm / args.gear_ratio,
args.current_limit,
args.ramp_ms,
args.hold_secs,
if args.dry_run {
" | dry-run"
} else if args.allow_partial {
" | allow-partial"
} else {
""
}
);
if args.dry_run {
println!("ℹ dry-run:只读反馈,不发出非零电流。\n");
} else {
println!(
"速度环 kp={} ki={} kd={} ff={} | 过速保护 {:.0} rpm",
args.kp,
args.ki,
args.kd,
args.feedforward,
args.overspeed_limit()
);
println!("⚠ 轮子必须架空或整车固定,急停可及;ctrl-c 随时安全停机。\n");
}
}
/// 打开链路 → 装配协议 bus → 执行 → **任何路径都先停电流再关总线**。
async fn run(args: &Args) -> Result<bool, Box<dyn std::error::Error>> {
let options = SlcanOptions {
serial_baud: args.serial_baud,
speed: args.can_speed.into(),
// 读写共用该超时(serial crate 的 TTYPort::write 也先 poll(POLLOUT)):
// 设太小会把"这一拍写不出去"误判成写失败。总线繁忙时读立即返回,
// 放大它只影响总线完全静默时的下行延迟。
read_timeout: Duration::from_millis(args.serial_timeout_ms),
};
let link = open_link_with(&args.device, options).map_err(|e| {
format!("打开 slcan 设备 {} 失败:{e}(检查设备路径 / 权限 / 是否被占用)", args.device)
})?;
println!("✓ slcan 已打开:{}(CAN {:?})", args.device, args.can_speed);
let bus = EscBus::spawn(link, args.esc_configs(), bus_options(), args.control_policy())?;
let outcome = drive(args, &bus).await;
// 安全收尾:先 0 电流并让它至少发出一拍,再关总线。
for motor in bus.motors() {
motor.disable();
}
sleep(Duration::from_millis(50)).await;
bus.shutdown().await;
println!("已停机(0 电流)并关闭总线");
outcome
}
async fn drive(
args: &Args,
bus: &EscBusHandle<MotorPolicy>,
) -> Result<bool, Box<dyn std::error::Error>> {
let motors = bus.motors();
wait_for_feedback(motors, Duration::from_millis(args.startup_timeout_ms)).await;
let targets: Vec<EscMotor> = motors.iter().filter(|m| is_fresh(m)).cloned().collect();
let missing: Vec<u8> =
motors.iter().filter(|m| !is_fresh(m)).map(MotorFeedback::id).collect();
if !missing.is_empty() {
eprintln!(
"✗ 以下电调没有新鲜反馈:{missing:?}(收到 {:?})",
motors.iter().map(MotorFeedback::feedback_age).collect::<Vec<_>>()
);
eprintln!(
" 排查:CAN 波特率(C620 = 1Mbps = S8)| CANH/CANL 是否接反 | 120Ω 终端电阻 | 电调供电 | 电调 CAN ID 拨码 | 设备 {}",
args.device
);
if !args.allow_partial {
eprintln!(" 若确认台架只接了部分电调,可加 --allow-partial 降级测试。");
return Ok(false);
}
eprintln!("⚠ --allow-partial:仅驱动有反馈的电调继续测试。\n");
}
if targets.is_empty() {
eprintln!("✗ 没有任何电调在反馈,无法测试。");
return Ok(false);
}
println!(
"✓ 参与测试的电调:{:?}(新鲜度 < {FRESH:?})\n",
targets.iter().map(MotorFeedback::id).collect::<Vec<_>>()
);
if args.dry_run {
return Ok(dry_run(&targets).await);
}
Ok(spin(args, &targets).await)
}
/// 该电调是否有**新鲜**反馈(而非历史上有过反馈)。
fn is_fresh(motor: &EscMotor) -> bool {
motor.feedback_age().is_some_and(|age| age <= FRESH)
}
/// 上电门控:等到每个电调都有一帧**新鲜**反馈;超时即返回 `false`。
async fn wait_for_feedback(motors: &[EscMotor], timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if motors.iter().all(is_fresh) {
return true;
}
if Instant::now() >= deadline {
return false;
}
sleep(Duration::from_millis(20)).await;
}
}
/// dry-run:只观察反馈,不下发任何非零电流(总线仍在发 0 电流帧)。
async fn dry_run(targets: &[EscMotor]) -> bool {
let mut ticker = interval(REPORT_PERIOD);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
ticker.tick().await;
for motor in targets {
let st = motor.state();
println!(
" {:<11} esc{} | 转子 {:>7.1} rpm | 温度 {:>3}℃ | 反馈 {:>6} 帧 | 新鲜度 {:?}",
motor.name(),
motor.id(),
st.rotor_speed_rpm,
st.temperature_c,
st.feedback_count,
motor.feedback_age().map(|age| age.as_millis())
);
}
println!();
}
println!("✓ 链路与反馈验证完成(未发出非零电流)");
true
}
/// 本次台架的安全包络:只开过速保护(`chassis-spin` 的历史行为)。
fn envelope(args: &Args) -> SafetyEnvelope {
SafetyEnvelope::overspeed_only(args.overspeed_limit(), OVERSPEED_GRACE)
}
/// 缓升 → 保持 → 采样;返回稳态是否达标。
async fn spin(args: &Args, motors: &[EscMotor]) -> bool {
let ramp = Duration::from_millis(args.ramp_ms);
let hold = Duration::from_secs_f32(args.hold_secs);
let overspeed_limit = args.overspeed_limit();
// 设定值曲线与安全判定都来自 chassis-bench 库:与 chassis-tune 的真机
// 后端共用同一套实现,避免"台架测的行为"和"整定优化的行为"分叉。
let scenario = Scenario::ramp_and_hold(args.rpm, ramp.as_secs_f32(), hold.as_secs_f32());
let mut ticker = interval(CONTROL_PERIOD);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let start = Instant::now();
let mut monitor = SafetyMonitor::new(envelope(args), start);
let mut next_report = REPORT_PERIOD;
let mut samples: Vec<Vec<f32>> = vec![Vec::new(); motors.len()];
let mut overspeed_warned = false;
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
println!("\n收到 ctrl-c,中断测试");
return false;
}
_ = ticker.tick() => {}
}
let elapsed = start.elapsed();
if elapsed >= ramp + hold {
break;
}
// 逐拍重发(= 命令租约续期),斜坡段目标线性上升。
let setpoint = scenario.setpoint_at(elapsed.as_secs_f32());
for motor in motors {
motor.set_velocity(setpoint);
}
// 过速保护:控制器失稳时立刻停机,别让轮子飞到最高转速。
let peak =
motors.iter().map(|m| m.state().rotor_speed_rpm.abs()).fold(0.0f32, f32::max);
if peak > overspeed_limit && !overspeed_warned {
overspeed_warned = true;
eprintln!("⚠ 过速 {peak:.0} rpm > 上限 {overspeed_limit:.0} rpm,观察中……");
}
if let Some(abort) = monitor.check(motors, Instant::now()) {
eprintln!(
"✗ 过速保护触发:{peak:.0} rpm 超过上限 {overspeed_limit:.0} rpm,立即停机({abort})。\n 速度环整定不当(kp/ki/前馈过大)——空载台架前馈应为 0。"
);
return false;
}
if elapsed >= ramp {
for (slot, motor) in samples.iter_mut().zip(motors) {
slot.push(motor.state().rotor_speed_rpm);
}
}
if elapsed >= next_report {
report_line(motors, setpoint, elapsed);
next_report += REPORT_PERIOD;
}
}
summarize(args, motors, &samples)
}
fn report_line(motors: &[EscMotor], setpoint: f32, elapsed: Duration) {
let rotor: Vec<String> =
motors.iter().map(|m| format!("{:>7.1}", m.state().rotor_speed_rpm)).collect();
let current: Vec<String> =
motors.iter().map(|m| format!("{:>6}", m.state().effort_raw)).collect();
let temp: Vec<String> =
motors.iter().map(|m| format!("{:>3}", m.state().temperature_c)).collect();
println!(
"[t={:>5.1}s] 目标 {:>7.1} | 转子 {} | 电流 {} | 温度 {}",
elapsed.as_secs_f32(),
setpoint,
rotor.join(" "),
current.join(" "),
temp.join(" ")
);
}
fn summarize(args: &Args, motors: &[EscMotor], samples: &[Vec<f32>]) -> bool {
if samples.iter().all(Vec::is_empty) {
println!("保持时长为 0,未采集到稳态样本");
return true;
}
println!(
"\n=== 稳态结论(目标转子 {:.1} rpm ≈ 轮端 {:.2} rpm)===",
args.rpm,
args.rpm / args.gear_ratio
);
let mut all_ok = true;
for (index, motor) in motors.iter().enumerate() {
let mean = mean(&samples[index]);
let error = if args.rpm.abs() > f32::EPSILON {
(mean - args.rpm).abs() / args.rpm.abs()
} else {
0.0
};
let ok = error <= TOLERANCE;
all_ok &= ok;
println!(
" {:<11} esc{} | 稳态 {:>7.1} rpm | 轮端 {:>6.2} rpm | 误差 {:>5.1}% | 电流 {:>6} raw | 温度 {:>3}℃ | {}",
motor.name(),
motor.id(),
mean,
mean / args.gear_ratio,
error * 100.0,
motor.state().effort_raw,
motor.state().temperature_c,
if ok { "✓" } else { "✗" }
);
}
if all_ok {
println!("\n✓ 通过:{} 个轮子均达到目标转速 ±{:.0}%", motors.len(), TOLERANCE * 100.0);
} else {
println!(
"\n✗ 未通过:存在轮速偏差 > {:.0}%,检查 PID / 机械阻力 / 电流限幅 / 方向配置",
TOLERANCE * 100.0
);
}
all_ok
}
fn mean(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
samples.iter().sum::<f32>() / samples.len() as f32
}
}
mod motor_one {
//! 单电机验证工具:让一个 C620 / GM3508 转起来,验证电机抽象全链路。
//!
//! ```sh
//! socketcan-spin [interface] [esc_id] [rotor_rpm] [duration_secs]
//! # 例:can0 上电调 1 以 1000 rpm(转子)转 10 秒
//! cargo run --release -p bench --bin socketcan-spin -- can0 1 1000 10
//! ```
//!
//! 关键点在 `spin_one`:它只依赖 `motor::Motor` 抽象,完全不知道
//! 背后是 C620 协议还是别的电机——换任何实现该 trait 的电机,这段代码
//! 原样可用。抽象(`can / imu`)、协议实现(`motor-protocol`)、设备层(`motor / esc-bus`)与总线后端(`driver crates`)在此汇合。
//!
//! 仅 Linux(需要 SocketCAN);其他平台编译为报错桩。
#[cfg(target_os = "linux")]
use std::time::Duration;
#[cfg(target_os = "linux")]
use esc_bus::{BusOptions, EscBus, EscConfig};
#[cfg(target_os = "linux")]
use motor::Motor;
#[cfg(target_os = "linux")]
use motor_control::{MotorPolicy, VelocityGains};
#[cfg(target_os = "linux")]
use socketcan::open_link;
#[cfg(target_os = "linux")]
use tokio::time::{MissedTickBehavior, interval};
#[cfg(target_os = "linux")]
pub async fn main_with_args(argv: Vec<String>) {
let mut args = argv.into_iter();
let interface = args.next().unwrap_or_else(|| "can0".into());
let esc_id: u8 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1);
let rotor_rpm: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(1000.0);
let duration_secs: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(10.0);
println!("=== 单电机抽象验证 ===");
println!(
"接口 {interface} | 电调 {esc_id} | 目标 {rotor_rpm} rpm(转子)| 时长 {duration_secs}s"
);
let link = open_link(&interface).unwrap_or_else(|e| {
eprintln!(
"打开 {interface} 失败:{e}(先 ip link set {interface} up type can bitrate 1000000)"
);
std::process::exit(1);
});
let config = EscConfig::gm3508("spin_test", esc_id, 1.0);
println!(
"电机配置:{:?} | 减速比 {:.3} | 电流限幅 {}",
config.model, config.gear_ratio, config.current_limit
);
let mut policy = MotorPolicy::new();
policy.configure(esc_id, config.current_limit, VelocityGains::new(10.0, 0.5, 0.0, 1.5));
let bus = EscBus::spawn(link, vec![config], BusOptions::default(), policy)
.expect("EscBus 配置非法");
let motor = bus.motor(esc_id).expect("刚配置的电机必然存在");
spin_one(motor, rotor_rpm, Duration::from_secs_f32(duration_secs)).await;
bus.shutdown().await;
println!("已退出");
}
/// 验证目标:本函数只面向 [`Motor`] 抽象——设定转速、周期读状态、停机。
/// 它不知道 CAN,不知道 C620,换任何 Motor 实现都原样工作。
///
/// 注意**命令租约**:`BusOptions::command_timeout` 默认 50ms,超过该时长未重发
/// 命令,驱动会强制 0 电流。因此这里用 10ms 的租约拍持续重发同一个设定值
/// (真实底盘的控制循环每 1ms 重发一次)。
#[cfg(target_os = "linux")]
async fn spin_one(motor: &impl Motor, target_rpm: f32, duration: Duration) {
const LEASE_PERIOD: Duration = Duration::from_millis(10);
const REPORT_PERIOD: Duration = Duration::from_millis(100);
motor.set_velocity(target_rpm);
println!("已下发目标转速,开始观测(100ms 周期)……\n");
let mut lease = interval(LEASE_PERIOD);
lease.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut report = interval(REPORT_PERIOD);
report.set_missed_tick_behavior(MissedTickBehavior::Skip);
let deadline = tokio::time::Instant::now() + duration;
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
println!("\n收到 ctrl-c");
break;
}
_ = lease.tick() => motor.set_velocity(target_rpm), // 续租
_ = report.tick() => {
let st = motor.state();
if st.has_feedback() {
println!(
"转子 {:>8.1} rpm | effort {:>6} raw | 温度 {:>3} ℃ | 累计角度 {:>8.3} 圈 | 反馈 {} 帧",
st.rotor_speed_rpm,
st.effort_raw,
st.temperature_c,
st.rotor_revolutions,
st.feedback_count
);
} else {
println!("⚠ 尚未收到任何反馈——检查电调供电与 CAN 接线");
}
}
}
if tokio::time::Instant::now() >= deadline {
break;
}
}
motor.disable();
// 让 0 电流帧至少发出一拍再关总线。
tokio::time::sleep(Duration::from_millis(50)).await;
println!("已停机(0 电流)");
}
#[cfg(not(target_os = "linux"))]
#[allow(clippy::unused_async)]
pub async fn main_with_args(_argv: Vec<String>) {
eprintln!("spin motor 仅支持 Linux(需要 SocketCAN)");
}
}
#[tokio::main]
async fn main() -> std::process::ExitCode {
let mut args = std::env::args().skip(1);
match args.next().as_deref() {
Some("chassis") => chassis::main_with_args(args.collect()).await,
Some("motor") => {
motor_one::main_with_args(args.collect()).await;
std::process::ExitCode::SUCCESS
}
Some("--help" | "-h") => {
println!("usage: spin <chassis|motor> [options]");
std::process::ExitCode::SUCCESS
}
_ => {
eprintln!("usage: spin <chassis|motor> [options]");
std::process::ExitCode::from(2)
}
}
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "tuning"
description = "PID tuning algorithms, search, cost, and presets"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
pid = { workspace = true }
motor-control = { workspace = true }
experiment = { workspace = true }
sim-harness = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
rand = { workspace = true }
rayon = { workspace = true }
argmin = { workspace = true }
argmin-math = { workspace = true }
thiserror = { workspace = true }
indicatif = { workspace = true }
tracing = { workspace = true }
motor-protocol = { workspace = true }
motor-model = { workspace = true }
[dev-dependencies]
approx = { workspace = true }
proptest = { workspace = true }
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,19 +1,19 @@
[Unit] [Unit]
Description=Sentry chassis daemon (mecanum, 4x GM3508/C620) Description=Sentry chassis daemon (mecanum, 4x GM3508/C620)
After=network.target After=network.target
# 若使用 USB 转 CAN,等待接口就绪:
# After=systemd-networkd.service
# RequiresMountsFor=/sys/class/net/can0
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/bin/chassisd --interface can0 --config /etc/chassisd/chassis.toml ExecStart=/usr/local/bin/chassisd --interface can0 --config /etc/chassisd/chassis.toml
Restart=always Restart=always
RestartSec=1 RestartSec=1
# 实时性:提升调度优先级(按需调整)
# CPUSchedulingPolicy=fifo # 实时调度由 systemd 设置;应用不调用 libc FFI。
# CPUSchedulingPriority=20 CPUSchedulingPolicy=fifo
# LimitRTPRIO=95 CPUSchedulingPriority=80
LimitRTPRIO=95
# 仅提高锁定额度;std 程序不调用 mlockall。
LimitMEMLOCK=infinity
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+4 -160
View File
@@ -1,162 +1,6 @@
# 架构设计 # 架构文档入口
## 分层 架构规范的唯一来源是仓库根目录的 [`ARCHITECTURE.md`](../ARCHITECTURE.md)。
```
┌────────────────┐ ┌──────────────────────┐ ┌─────────────────────────┐
│ chassisd │ │ chassis-tune │ │ chassis-bench │
│ 守护进程 │ │ 自动整定(搜索/报告) │ │ 台架(激励/包络/采样) │
│ 1kHz 循环 / 安全│ │ sim ↓ bench ↓ │ │ chassis-spin 等 │
└───┬────────┬───┘ └────┬──────────┬──────┘ └────┬───────────────────┘
│ │ │ │ │
│ │ │ └───────┬───────┘
▼ ▼ ▼ ▼
┌───────────┐ ┌──────────────────┐ ┌───────────────────────┐
│ mecanum │ │ dji-esc-sim │ │ dji-esc-slcan │
│ 麦轮运动学 │ │ 被控对象 / 场景 │ │ dji-esc-socketcan │
└───────────┘ │ 离线 harness │ │ 真实总线 ↔ CanLink │
│ 异步假电调 │ └──────────┬────────────┘
└────────┬─────────┘ │
│ CanLink(tokio mpsc 通道对)
▼ ▼
┌────────────────────┐ ┌──────────────────┐
│ motor-core │◄─│ dji-esc │
│ 能力 trait / Pid │ │ C620/C610 协议 │
│ VelocityGains │ │ EscBus 异步驱动 │
└────────────────────┘ └──────────────────┘
┌────────────────────┐
│ chassis-config │ chassis.toml schema(chassisd + chassis-tune 共用)
└────────────────────┘
```
核心解耦点:`CanLink` 是一对 tokio 通道(发送 / 接收 `CanFrame`)。`dji-esc` 的协议状态机
只面向通道工作,因此:
- 闭环仿真由 `dji-esc-sim` 提供两条路径:**异步假电调**(挂在同一对通道上,`chassisd --mock`
与集成测试用)与**同步 harness**(纯浮点积分、无任务无通道,自动整定用,可并行上千次);
- `dji-esc-socketcan`(板载原生 SocketCAN)与 `dji-esc-slcan`(CANable 等 USB 适配器,
串口 Lawicel 协议)只把真实总线桥接到通道,**不含任何应用逻辑**;应用住在 `chassis-bench`
(台架)与 `chassis-tune`(整定);
- 未来更换总线介质(串口转 CAN、MCU 转发)只需新增一个桥接 crate。
## 被控对象模型(dji-esc-sim)
逐拍离散(`dt` 秒),电调坐标系(正电流 → 正转速):
1. **命令延迟**:`current_raw` 进 FIFO,`delay_ticks` 拍后才作用(链路 + 采样延迟);
2. **摩擦**:运动时 `net = u − sign(rpm)·coulomb − viscous·rpm`;静止且 `|u| > static` 时
`net = u − sign(u)·coulomb`;静止且 `|u| ≤ static` 时 `net = 0`(粘住);
3. **一阶转速**:`rpm_target = clamp(net × gain, ±max_rpm) − load_rpm`,
以时间常数 `tau` 一阶趋近;
4. **量化**:机械角按转速积分并对 8192 取模,反馈帧是 `EscFeedback`,
因此角度被截断为 `u16`、转速为 `i16`——量化由协议类型自带。
`PlantParams::gm3508()` 是**无摩擦无延迟**的理想模型(`chassisd --mock` 与历史闭环测试依赖它,
行为逐位不变);`PlantParams::gm3508_bench()` 叠加静 / 库仑摩擦(400~600 raw 量级,
取自台架实测)与 2 拍延迟,是整定的默认被控对象。
单点模型整定出来的增益往往对增益 / 时间常数 / 延迟高度敏感,因此 `chassis-tune` 默认在
`PlantEnsemble`(域随机化:增益 ±20%、时间常数 ±30%、延迟 0~3 拍、摩擦 ±50%)上做鲁棒优化,
而不是针对一条理想曲线做最优化。
## 自动整定(chassis-tune)
两阶段搜索:
1. **粗扫**:Latin Hypercube 分层采样 + `rayon` 并行评估,用有限次数铺满四维空间;
2. **精修**:以成本最低的 top-k 为起点,在**坐标空间**(对数刻度,增益恒正)做多起点
`argmin` 局部搜索(Nelder-Mead 默认,可选 PSO)。
成本 = 归一化加权(调节时间 / 超调 / IAE / 电流);**硬约束不进权重**:过速、饱和占比过高、
不收敛直接给罚值并记录判定——否则搜索会主动挑一个会飞车的解。
可复现性:每个候选的随机性只来自 `(种子, 候选下标)` 派生的独立 RNG,粗扫结果排序后再取 top-k,
因此同 seed 两次运行**逐位一致**(CI 的 `just tune-smoke` 依赖这一点)。
超参数(参数空间 / 场景 / 权重 / 验收阈值 / 搜索预算 / 被控对象 / 真机安全包络)不在命令行
默认值里散着,而是版本化在 `config/tune.toml` 的具名预设中(见
[`preset.rs`](../crates/chassis-tune/src/preset.rs))。优先级为
**内置默认 < 预设文件 < 显式 CLI**,逐字段覆盖;`current_limit` / `direction` / `gear_ratio`
不是预设字段,写进预设文件会直接报错(安全参数不允许被预设绕过)。
真机后端(`--backend bench`)与仿真后端实现同一个 `Evaluator` trait,区别只是"评估一组增益"
从浮点积分换成真机试验:打开链路 → 上电门控(反馈新鲜)→ 逐次热更新增益
(`EscMotor::set_velocity_gains`,不重启总线)→ 逐拍重发设定值 + 安全包络 → 任何退出路径
先 0 电流 → 试验间静默。搜索强制单线程,因为只有一条总线。
## 命令租约与链路深度(实车踩过的坑)
1. **命令是租约**:`EscBus` 在 `command_timeout`(默认 50ms)内没收到新命令就强制 0 输出
(防上层任务死亡后旧指令持续输出)。所以 `set_velocity` 不是"设一次就完事",上层必须
逐拍重发;只在开头下发一次的台架脚本会在 50ms 后自动失电。
2. **命令队列必须浅**:驱动按 1kHz 生产命令,而 slcan 一次 write 在 CDC ACM 上约 2ms,
下行只有几百帧/秒。若 `CanLink` 命令方向缓冲很深(早期是 512),队列会被过时命令灌满,
ESC 执行的是**一秒多以前**算出的电流——速度环等于开了 1.4s 延迟,轮子必然跑飞。
现在 slcan 桥的 `CHANNEL_BOUND = 8`:`send().await` 立即回压,每一帧都按最新反馈现算。
反馈方向是采样流,来不及就丢(`try_send`),绝不阻塞泵线程。
3. **桥的两个方向不能互相饿死**:发送要有上界(`TX_BURST`)并在一帧写不动时立刻让位,
接收要批量排空(`RX_BURST`)。`serial` crate 的 `TTYPort` 读写共用同一个超时,
写超时属正常现象,不能当致命错误(否则一次写超时就会把接收方向一起带停)。
## DJI 电调 CAN 协议要点(C620 手册)
| 项目 | 值 |
| --- | --- |
| 控制帧 ID | `0x200`(电调 1–4)、`0x1FF`(电调 5–8) |
| 控制帧格式 | 8 字节 = 4 × i16 大端电流命令,按电调 ID 顺序 |
| C620 电流标度 | ±16384 ↔ ±20 A |
| C610 电流标度 | ±10000 ↔ ±10 A(M2006 组合,接硬件前需核对) |
| 反馈帧 ID | `0x200 + 电调ID`(`0x201`–`0x208`) |
| 反馈帧格式 | u16 角度(0..8191) + i16 转速rpm + i16 转矩电流 + i8 温度℃,均大端 |
| 反馈频率 | 每电调 1kHz |
C620 内部**只闭电流环**:转速 / 位置环由主控侧 PID 实现(`motor-core::Pid`,
挂在 `EscBus` 的 1kHz 发送节拍上)。GM3508 减速比 3591 / 187 ≈ 19.203。
## 单位与方向约定
- 机器人坐标系:ISO 约定——x 前、y 左、ω 逆时针为正(见 `mecanum`)。
- `Motor` 接口呈现的是**物理量**(安装方向修正后):
`EscConfig::direction = -1` 时,驱动层在写命令与读反馈时统一翻转符号,
上层永远以"轮子正转 = 底盘前进方向"的正向工作。
- `MotorState::rotor_speed_rpm` 为电机转子转速;轮端转速 = 转子转速 / 减速比。
- 电流一律以协议原始值(raw)为主,安培换算通过 `EscModel` 常量提供。
## 安全设计
1. **上电门控**:底盘循环在收到任一电机首帧反馈前不下发速度命令。
2. **反馈离线**:任一电机反馈超时(默认 100ms)→ 该电机命令强制 0 电流并告警。
3. **命令超时**:上层 twist 命令超时(默认 200ms)→ 目标速度归零。
4. 所有超时可经 `chassis.toml` 配置。
## 测试策略
| 层级 | 内容 | 平台 |
| --- | --- | --- |
| 单元测试 | 帧编解码字节级样例 / 饱和、多圈回绕、PID(阶跃、抗饱和、滤波)、运动学解析解 + 属性测试、slcan 帧形状过滤 | 全平台 |
| 闭环仿真 | 内存通道 + 一阶电机模型:4 电机速度环收敛、群帧组装、离线安全、**命令租约到期与续租**、**增益热更新**、守护进程全链路 | 全平台 |
| 模型一致性 | 同步 harness 与 `EscBus` 异步闭环在同一组增益下的稳态 / effort 对齐(防止"对着错模型调参") | 全平台 |
| 整定 | 指标(合成轨迹解析解)、参数空间(界内 / 确定性 / 对数分布 / 分层覆盖)、成本(硬约束罚值)、搜索(同 seed 逐位复现、优于手调基线)、报告(CSV/TOML/JSON 往返) | 全平台(CI 跑 `just tune-smoke`) |
| 超参数预设 | schema 往返、逐字段覆盖与优先级、未知键 / 越界 / 缺失报错、`config/tune.toml` 全部预设可解算、命名与后端一致、安全参数不可注入 | 全平台 |
| vcan 集成 | vcan0 双 socket 收发,真实内核 CAN 栈 | Linux(`#[ignore]`,CI 显式跑) |
| 实车台架 | `chassis-spin`(`chassis-bench`)四轮定速旋转:链路/反馈、方向、转速稳态、过速保护、安全停机;`chassis-tune --backend bench` 逐次试验 + 重复性复验 | armv7 目标板 + slcan/SocketCAN(人工) |
| 交叉检查 | `aarch64-unknown-linux-gnu` 全 workspace check | CI |
## 部署
`chassisd` 为单二进制守护进程:`--interface can0 --config /etc/chassisd/chassis.toml`。
systemd 样例见 `deploy/chassisd.service`。板端 CAN 两条路径:
- **SocketCAN**:USB 转 CAN(gs_usb / candleLight)或 SPI CAN HAT(MCP2515)驱动加载后的 `can0`;
- **slcan**:USB 适配器(CANable2 等)在 CDC ACM 下枚举为 `/dev/ttyACM0`,由
[`dji-esc-slcan`](../crates/dji-esc-slcan) 在用户态处理(不需 `ip link`,也绕开了
gs_usb 驱动)。台架工具 `chassis-spin`([`chassis-bench`](../crates/chassis-bench))与
自动整定的真机后端走这条路径。整定工具本身是宿主机 / 板端通用的纯 Rust 程序,
`current_limit` 等安全参数由 CLI 显式给出,不参与搜索。
## 相关文档
- [底盘开发文档](chassis.md)——接线 / 标定 / 上车流程 / 自动整定与超参数预设 / 故障排查
- [整体架构规划](../ARCHITECTURE.md)——全系统分层与阶段计划
本文件不再复制分层图或依赖规则,以免两份架构文档漂移。接线、标定、台架、
自动整定和故障排查见 [`chassis.md`](chassis.md)。
+49 -94
View File
@@ -32,12 +32,11 @@
| 文档 | 回答的问题 | 读者 | | 文档 | 回答的问题 | 读者 |
| --- | --- | --- | | --- | --- | --- |
| [`ARCHITECTURE.md`](../ARCHITECTURE.md) | 整套 RoboMaster CAN 控制系统的**全系统分层规划**(含云台、发射机构、delta10 IMU、T153 实时性、9 电机) | 架构决策者 | | [`ARCHITECTURE.md`](../ARCHITECTURE.md) | 职责型 workspace、允许依赖、std/unsafe 策略、实现边界 | 架构决策者 |
| [`docs/architecture.md`](architecture.md) | 本仓库**已实现**部分的解耦点:`CanLink` seam、被控对象模型、自动整定两阶段搜索、测试策略 | 改代码的人 | | [`docs/architecture.md`](architecture.md) | 架构文档入口,不复制规范 | 改代码的人 |
| **本文** | **底盘这一块**怎么开发、怎么标定、怎么整定、怎么上车、出事怎么办 | 底盘开发 / 调试 / 上车的人 | | **本文** | **底盘这一块**怎么开发、标定、整定、上车与排障 | 底盘开发 / 调试 / 上车的人 |
`ARCHITECTURE.md` 里的 `rm-*` crate 划分是**规划**;本仓库实际落地为下面这套 crate 命名 本文只覆盖四轮底盘切片;云台、发射机构与 DETA10 真实载荷布局不在当前范围内。
(功能对应关系见第 5 节)。本文只覆盖底盘切片,云台 / 发射 / IMU 不在范围内。
--- ---
@@ -46,7 +45,7 @@
### 2.1 无硬件(macOS / Linux / CI 均可) ### 2.1 无硬件(macOS / Linux / CI 均可)
```sh ```sh
cargo test --workspace # 单测 + 闭环仿真 + 整定,全部无需硬件 cargo test --workspace --all-targets # 单测 + 闭环仿真 + 整定,全部无需硬件
cargo run -p chassisd -- --mock # 进程内电机模型的全链路演示 cargo run -p chassisd -- --mock # 进程内电机模型的全链路演示
cargo run -p chassis-tune -- presets # 看有哪些整定超参数预设 cargo run -p chassis-tune -- presets # 看有哪些整定超参数预设
just tune-smoke # 确定性整定冒烟(CI 同款) just tune-smoke # 确定性整定冒烟(CI 同款)
@@ -57,10 +56,10 @@ just lint # fmt + clippy(与 CI 同标准)
```sh ```sh
# 1) 只验链路与反馈,绝不发非零电流(接线排查第一步) # 1) 只验链路与反馈,绝不发非零电流(接线排查第一步)
cargo run --release -p chassis-bench --bin chassis-spin -- --device /dev/ttyACM0 --dry-run cargo run --release -p spin -- chassis --device /dev/ttyACM0 --dry-run
# 2) 四轮定性旋转(轮子必须架空或整车固定,急停可及) # 2) 四轮定性旋转(轮子必须架空或整车固定,急停可及)
cargo run --release -p chassis-bench --bin chassis-spin -- --device /dev/ttyACM0 --rpm 500 cargo run --release -p spin -- chassis --device /dev/ttyACM0 --rpm 500
# 3) 先离线整定,再上台架复验(用版本化的超参数预设) # 3) 先离线整定,再上台架复验(用版本化的超参数预设)
cargo run --release -p chassis-tune -- sweep --preset config/tune.toml --preset-name sim_bench_model cargo run --release -p chassis-tune -- sweep --preset config/tune.toml --preset-name sim_bench_model
@@ -89,8 +88,8 @@ T153 (Linux) Gm3508 × 4 + C620
| 路径 | 打开方式 | 适用 | | 路径 | 打开方式 | 适用 |
| --- | --- | --- | | --- | --- | --- |
| **slcan**(`dji-esc-slcan`) | 用户态直接读写 `/dev/ttyACM0`(Lawicel ASCII 协议) | 免 `ip link`、绕开 `gs_usb` 驱动;台架与初始联调首选 | | **slcan**(`slcan`) | 用户态直接读写 `/dev/ttyACM0`(Lawicel ASCII 协议) | 免 `ip link`、绕开 `gs_usb` 驱动;台架与初始联调首选 |
| **SocketCAN**(`dji-esc-socketcan`) | `ip link set can0 up type can bitrate 1000000 restart-ms 100` | 板载原生 CAN;生产部署 | | **SocketCAN**(`socketcan`) | `ip link set can0 up type can bitrate 1000000 restart-ms 100` | 板载原生 CAN;生产部署 |
### 3.2 设备与协议常量(C620 手册) ### 3.2 设备与协议常量(C620 手册)
@@ -105,11 +104,11 @@ T153 (Linux) Gm3508 × 4 + C620
| 反馈频率 | 每电调 1 kHz | | 反馈频率 | 每电调 1 kHz |
| 减速比 | GM3508 = 3591 / 187 ≈ **19.203**(转子 : 输出轴) | | 减速比 | GM3508 = 3591 / 187 ≈ **19.203**(转子 : 输出轴) |
C620 **内部只闭电流环**:转速环 / 位置环由主控侧实现(`motor-core::Pid`,挂在 `EscBus` 的 1 kHz 节拍上)。 C620 **内部只闭电流环**:转速环 / 位置环由主控侧实现(`motor-control::Pid`,挂在 `EscBus` 的 1 kHz 节拍上)。
### 3.3 电调 ID 与安装方向 ### 3.3 电调 ID 与安装方向
默认约定(`config/chassis.toml.example`、`chassis-spin` 默认值一致): 默认约定(`config/chassis.toml.example`、`spin chassis` 默认值一致):
| 轮位 | 轮序下标 | 电调 ID | 安装方向 | | 轮位 | 轮序下标 | 电调 ID | 安装方向 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -125,7 +124,7 @@ C620 **内部只闭电流环**:转速环 / 位置环由主控侧实现(`moto
| 场景 | 建议限幅(raw) | 说明 | | 场景 | 建议限幅(raw) | 说明 |
| --- | --- | --- | | --- | --- | --- |
| 空载台架(`chassis-spin` 默认) | **1500**(≈1.8 A) | 需越过静摩擦(实测 400~600 raw)并留整定余量 | | 空载台架(`spin chassis` 默认) | **1500**(≈1.8 A) | 需越过静摩擦(实测 400~600 raw)并留整定余量 |
| 整车实车 | 6000 ~ 12000 | 视供电与机械而定;同时是 PID 抗饱和的钳位点 | | 整车实车 | 6000 ~ 12000 | 视供电与机械而定;同时是 PID 抗饱和的钳位点 |
> **限流不是"越大越安全"**:它同时是 PID 输出的钳位点。设得远大于实际所需, > **限流不是"越大越安全"**:它同时是 PID 输出的钳位点。设得远大于实际所需,
@@ -135,7 +134,7 @@ C620 **内部只闭电流环**:转速环 / 位置环由主控侧实现(`moto
- 电调供电与主控**共地**;CAN_H / CAN_L 不接反,两端 120Ω 终端电阻齐全。 - 电调供电与主控**共地**;CAN_H / CAN_L 不接反,两端 120Ω 终端电阻齐全。
- 轮子**架空**或整车固定;急停开关在手可及处。 - 轮子**架空**或整车固定;急停开关在手可及处。
- 首次上电**先 `--dry-run`**,确认四轮反馈都在刷(`chassis-spin` 会打印每个电调的 - 首次上电**先 `--dry-run`**,确认四轮反馈都在刷(`spin chassis` 会打印每个电调的
转速 / 温度 / 反馈年龄)。 转速 / 温度 / 反馈年龄)。
--- ---
@@ -161,7 +160,7 @@ v_RR = vx - vy + a·ω
``` ```
正解是逆解的左伪逆(JᵀJ 对角,可解析求平均)。实现见 正解是逆解的左伪逆(JᵀJ 对角,可解析求平均)。实现见
[`crates/mecanum`](../crates/mecanum):`Mecanum::wheel_speeds` / `twist` / [`mecanum`](../crates/mecanum):`Mecanum::wheel_speeds` / `twist` /
`wheel_rpms` / `twist_from_wheel_rpms`。 `wheel_rpms` / `twist_from_wheel_rpms`。
### 4.3 单位与层级(最容易出错的地方) ### 4.3 单位与层级(最容易出错的地方)
@@ -175,7 +174,7 @@ v_RR = vx - vy + a·ω
| 控制量 effort | 驱动 / PID | 协议 **raw**(ESC = 电流,C620 ±16384) | | 控制量 effort | 驱动 / PID | 协议 **raw**(ESC = 电流,C620 ±16384) |
| `kp/ki/kd/feedforward` | 速度环 | raw/rpm、raw/(rpm·s)、raw/(rpm/s)、raw/rpm | | `kp/ki/kd/feedforward` | 速度环 | raw/rpm、raw/(rpm·s)、raw/(rpm/s)、raw/rpm |
- **减速比换算只发生在一个地方**:`motor-core::OutputShaft` 包装(`转子 = 输出轴 × gear_ratio`)。 - **减速比换算只发生在一个地方**:`motor / esc-bus::OutputShaft` 包装(`转子 = 输出轴 × gear_ratio`)。
`EscBus` / `EscMotor` 只认转子,绝不碰减速比;`EscConfig::gear_ratio` 仅作配置元数据。 `EscBus` / `EscMotor` 只认转子,绝不碰减速比;`EscConfig::gear_ratio` 仅作配置元数据。
- PID 的接线顺序:`ChassisController` 算出**轮端** rpm → `OutputShaft` 换算成转子 rpm → - PID 的接线顺序:`ChassisController` 算出**轮端** rpm → `OutputShaft` 换算成转子 rpm →
`EscMotor` 的速度环以转子 rpm 闭环。 `EscMotor` 的速度环以转子 rpm 闭环。
@@ -184,77 +183,34 @@ v_RR = vx - vy + a·ω
## 5. 软件架构 ## 5. 软件架构
### 5.1 Crate 分层与职责 ### 5.1 职责边界
**依赖只能向下**(上层可依赖下层,反之不行): 完整 crate 清单和允许依赖见 [`ARCHITECTURE.md`](../ARCHITECTURE.md)。底盘路径上的关键职责是:
``` | 职责 | crate |
第 4 层 应用 / 工具 chassisd chassis-tune chassis-bench | --- | --- |
(含两个 bin) | CAN 帧、总线接口、链路与 Mock | `can` |
第 3 层 领域 chassis-config mecanum dji-esc-sim | 电调协议 | `motor-protocol` |
(schema / 运动学 / 被控对象 + 离线 harness) | 设备契约、命令/状态/减速比/注册表 | `motor` |
第 2 层 协议 / 设备 dji-esc(C620 编解码 + EscBus 驱动 + 命令租约) | 帧泵、租约、反馈新鲜度、零电流 | `esc-bus` |
第 1 层 抽象 can-link(CanFrame + 通道对) motor-core(能力 trait + PID) | PID、速度/位置环、组合策略 | `pid`、`motor-control` |
第 0 层 传输 dji-esc-slcan(串口) / dji-esc-socketcan(内核 CAN) | 麦轮运动学 | `mecanum` |
``` | 配置 schema | `chassis-config` |
| 台架安全与采样 | `bench` |
| 应用入口 | `chassisd`、`spin`、`chassis-tune` |
`dji-esc-slcan` / `dji-esc-socketcan` 只实现 `can-link` 的通道语义,**不含任何应用逻辑**; crate 内源码保持扁平,不建立 `src/<module>/` 子目录。
它们被第 4 层的工具与守护进程选用,而不是被协议层依赖。
| Crate | 职责 | 平台 | ### 5.2 依赖与组合
| --- | --- | --- |
| [`motor-core`](../crates/motor-core) | 能力 trait(`VelocityDrive` 等)、`MotorState`、`Pid`、`VelocityController`、`OutputShaft` | 纯逻辑,全平台 |
| [`can-link`](../crates/can-link) | `CanFrame` + `CanLink` 通道对(**无协议知识**) | 纯逻辑,全平台 |
| [`dji-esc`](../crates/dji-esc) | C620/C610 编解码、多圈累计、`EscBus` 异步驱动、命令租约 | 纯逻辑,全平台 |
| [`dji-esc-sim`](../crates/dji-esc-sim) | 被控对象模型、场景、**离线 harness**、异步假电调 | 纯逻辑,全平台 |
| [`dji-esc-slcan`](../crates/dji-esc-slcan) | slcan(Lawicel)串口 ↔ `CanLink` 桥接 | 全平台 |
| [`dji-esc-socketcan`](../crates/dji-esc-socketcan) | SocketCAN ↔ `CanLink` 桥接 | Linux |
| [`mecanum`](../crates/mecanum) | 麦轮正 / 逆运动学(纯数学) | 全平台 |
| [`chassis-config`](../crates/chassis-config) | `chassis.toml` schema:解析 / 校验 / 写回 | 全平台 |
| [`chassis-bench`](../crates/chassis-bench) | 台架:安全包络、采样、`chassis-spin` / `socketcan-spin` | 全平台(SocketCAN 工具需 Linux) |
| [`chassis-tune`](../crates/chassis-tune) | 速度环 PID 自动整定 + **超参数预设** | 全平台 |
| [`chassisd`](../crates/chassisd) | 底盘守护进程:1 kHz 循环、安全逻辑、指令源 seam | 全平台(实车需 Linux) |
### 5.2 依赖方向(硬约束) `esc-bus` 不依赖 PID 或 `motor-control`。`motor::EffortPolicy` 是两者的组合点:
应用构造 `motor-control::MotorPolicy`,并把它传给 `EscBus::spawn`。
``` 总线仍然在 1 kHz 节拍上运行策略,因此控制时序不变;变化的是控制律不再被封在总线驱动里。
chassisd ─┬─► chassis-config ─► motor-core, mecanum 反馈离线、命令租约到期和模式切换会把对应策略状态复位并强制零输出。
├─► dji-esc ──► can-link, motor-core
├─► dji-esc-sim ─► dji-esc(协议类型), can-link
└─► dji-esc-socketcan ─► can-link
chassis-tune ─┬─► dji-esc-sim(离线 harness) `bench` 只产生试验轨迹和安全中止原因;指标、成本、搜索和报告适配由
├─► chassis-bench(安全包络 + 采样) `tuning` 与 `chassis-tune` 完成。
├─► chassis-config(共用 schema)
└─► dji-esc ─► can-link, motor-core
chassis-bench ─┬─► dji-esc ─► can-link, motor-core
├─► dji-esc-sim(场景 / 轨迹类型)
├─► dji-esc-slcan / dji-esc-socketcan
└─► chassis-config
```
**传输后端 crate 只做桥接,不含应用逻辑**:`chassis-spin` 曾住在 `dji-esc-slcan` 的
example 里,现在统一收归 `chassis-bench`;整定逻辑住在 `chassis-tune`。要换总线介质
(串口转 CAN、MCU 转发),只需**新增一个桥接 crate**,协议 / 控制 / 整定全部不动。
### 5.3 一个 1 ms 拍里发生了什么
```
tick (1ms)
├─ 读取 watch<Twist>(有新值则更新,记录时间戳)
├─ 指令超时判定(默认 200ms)→ 超时则 Twist::ZERO
├─ 幅值限幅 Twist::clamped(max_vx, max_vy, max_omega)(按比例收缩,保方向)
├─ 上电门控:四轮是否都收到过反馈?否则全部 disable 并告警
├─ 麦轮逆解 → 四轮轮端 rpm
├─ OutputShaft 换算 → 转子 rpm
└─ set_velocity(续租命令)→ EscBus 在自身 1 kHz 节拍上组帧、发送
```
同时 `EscBus` 侧持续:接收反馈帧 → 分发到各 `EscMotor` → 更新 `MotorState`
→ 检查反馈离线(默认 100 ms)→ 检查命令租约(默认 50 ms)。
---
## 6. 关键机制(实车踩过的坑) ## 6. 关键机制(实车踩过的坑)
@@ -292,7 +248,7 @@ tick (1ms)
## 7. 控制设计 ## 7. 控制设计
### 7.1 PID(`motor-core::Pid`) ### 7.1 PID(`motor-control::Pid`)
并联式 PID,显式时间步 `dt`(秒),特性: 并联式 PID,显式时间步 `dt`(秒),特性:
@@ -302,7 +258,7 @@ tick (1ms)
4. 可选**微分一阶低通**(`with_derivative_tau`),抑制编码器量化噪声; 4. 可选**微分一阶低通**(`with_derivative_tau`),抑制编码器量化噪声;
5. `dt <= 0` 或非有限输入 → 返回上次输出且不更新内部状态(防御跳帧 / 首帧)。 5. `dt <= 0` 或非有限输入 → 返回上次输出且不更新内部状态(防御跳帧 / 首帧)。
### 7.2 速度环(`motor-core::VelocityController`) ### 7.2 速度环(`motor-control::VelocityController`)
``` ```
effort_raw = clamp( PID(target_rpm, measured_rpm, dt) + feedforward × target_rpm, ±limit ) effort_raw = clamp( PID(target_rpm, measured_rpm, dt) + feedforward × target_rpm, ±limit )
@@ -324,7 +280,7 @@ effort_raw = clamp( PID(target_rpm, measured_rpm, dt) + feedforward × target_rp
| 一起动就飞 | 前馈过大 or 限流远大于实际所需 | ff → 0、限流贴近实际 | | 一起动就飞 | 前馈过大 or 限流远大于实际所需 | ff → 0、限流贴近实际 |
| 稳态有静差 | 摩擦未被积分吃掉 | 检查 `ki` 是否被抗饱和长期冻结 | | 稳态有静差 | 摩擦未被积分吃掉 | 检查 `ki` 是否被抗饱和长期冻结 |
### 7.4 被控对象模型(`dji-esc-sim`) ### 7.4 被控对象模型(`sim-harness::motor_model`)
逐拍离散(`dt` 秒,电调坐标系:正电流 → 正转速): 逐拍离散(`dt` 秒,电调坐标系:正电流 → 正转速):
@@ -394,9 +350,9 @@ schema 由 [`chassis-config`](../crates/chassis-config) 定义,**守护进程
| # | 步骤 | 命令 | 通过判据 | | # | 步骤 | 命令 | 通过判据 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 1 | **链路与反馈** | `chassis-spin --dry-run` | 四个电调都在刷反馈(转速 ≈ 0、温度正常、反馈年龄 < 100 ms)。缺一个就查线 / 电调 ID | | 1 | **链路与反馈** | `spin chassis --dry-run` | 四个电调都在刷反馈(转速 ≈ 0、温度正常、反馈年龄 < 100 ms)。缺一个就查线 / 电调 ID |
| 2 | **电调 ID 对应** | 同 1,逐个拔插 | 拔掉哪个电调,哪个 ID 的反馈消失 → ID 映射正确 | | 2 | **电调 ID 对应** | 同 1,逐个拔插 | 拔掉哪个电调,哪个 ID 的反馈消失 → ID 映射正确 |
| 3 | **方向** | `chassis-spin --rpm 200`(低速) | 四个轮子都朝"底盘前进"方向转;反了就改 `direction` | | 3 | **方向** | `spin chassis --rpm 200`(低速) | 四个轮子都朝"底盘前进"方向转;反了就改 `direction` |
| 4 | **减速比 / 轮径** | 手转一圈 + 读反馈角度 | 反馈角度 × 减速比 ≈ 轮子转过角度;不符则改 `gear_ratio` | | 4 | **减速比 / 轮径** | 手转一圈 + 读反馈角度 | 反馈角度 × 减速比 ≈ 轮子转过角度;不符则改 `gear_ratio` |
| 5 | **几何参数** | 卷尺量 `half_length` / `half_width` / `wheel_radius` | 填入 `chassis.toml`(影响运动学精度,不影响稳定性) | | 5 | **几何参数** | 卷尺量 `half_length` / `half_width` / `wheel_radius` | 填入 `chassis.toml`(影响运动学精度,不影响稳定性) |
| 6 | **限流** | 从 1500 raw 起,逐步加到"能可靠起动且不冲" | 越小越好;实车再按需上调 | | 6 | **限流** | 从 1500 raw 起,逐步加到"能可靠起动且不冲" | 越小越好;实车再按需上调 |
@@ -485,7 +441,7 @@ chassis-tune sweep --preset config/tune.toml --samples 128 # CLI 覆盖预
| `field_updown` | bench | **up-down** 1200 rpm | 实战验收:升 / 降 / 回零双向,阈值收紧到 12% / 3% | | `field_updown` | bench | **up-down** 1200 rpm | 实战验收:升 / 降 / 回零双向,阈值收紧到 12% / 3% |
**预设 schema**(`version = 1`,未知键报错,见 **预设 schema**(`version = 1`,未知键报错,见
[`crates/chassis-tune/src/preset.rs`](../crates/chassis-tune/src/preset.rs)): [`crates/tuning/src/lib.rs`](../crates/tuning/src/lib.rs)):
```toml ```toml
version = 1 version = 1
@@ -556,7 +512,7 @@ feedforward = { min = 0.0, max = 0.0, scale = "linear" } # 单点区间 = 搜
## 11. 台架工具 ## 11. 台架工具
### 11.1 `chassis-spin`(`chassis-bench`,slcan,全平台) ### 11.1 `spin chassis`(`spin`,slcan,全平台)
四轮同时以固定**转子**转速旋转。关键参数: 四轮同时以固定**转子**转速旋转。关键参数:
@@ -586,13 +542,13 @@ feedforward = { min = 0.0, max = 0.0, scale = "linear" } # 单点区间 = 搜
- 上电门控:四个电调都上报新鲜反馈前绝不下发速度; - 上电门控:四个电调都上报新鲜反馈前绝不下发速度;
- 空载的合适整定与带载**完全不同**,空载**不要**加前馈。 - 空载的合适整定与带载**完全不同**,空载**不要**加前馈。
### 11.2 `socketcan-spin`(`chassis-bench`,仅 Linux) ### 11.2 `spin motor`(`spin`,仅 Linux)
单电机链路验证,走 SocketCAN。用于板载原生 CAN 的接线排查: 单电机链路验证,走 SocketCAN。用于板载原生 CAN 的接线排查:
```sh ```sh
ip link set can0 up type can bitrate 1000000 restart-ms 100 ip link set can0 up type can bitrate 1000000 restart-ms 100
cargo run --release -p chassis-bench --bin socketcan-spin -- --interface can0 --dry-run cargo run --release -p spin -- motor -- --interface can0 --dry-run
``` ```
--- ---
@@ -632,7 +588,7 @@ cargo run --release -p chassis-bench --bin socketcan-spin -- --interface can0 --
| 整定 | 指标(合成轨迹解析解)、参数空间(界内 / 确定性 / 对数分布 / 分层覆盖)、成本(硬约束罚值)、搜索(同 seed 逐位复现、优于手调基线)、报告(CSV/TOML/JSON 往返) | 全平台(CI 跑 `just tune-smoke`) | | 整定 | 指标(合成轨迹解析解)、参数空间(界内 / 确定性 / 对数分布 / 分层覆盖)、成本(硬约束罚值)、搜索(同 seed 逐位复现、优于手调基线)、报告(CSV/TOML/JSON 往返) | 全平台(CI 跑 `just tune-smoke`) |
| **预设** | schema 往返、逐字段覆盖、优先级(CLI > 预设 > 内置)、未知键 / 越界 / 缺失报错、**`config/tune.toml` 全部预设可解算**、命名与后端一致、安全参数不可注入 | 全平台 | | **预设** | schema 往返、逐字段覆盖、优先级(CLI > 预设 > 内置)、未知键 / 越界 / 缺失报错、**`config/tune.toml` 全部预设可解算**、命名与后端一致、安全参数不可注入 | 全平台 |
| vcan 集成 | vcan0 双 socket 收发,真实内核 CAN 栈 | Linux(`#[ignore]`,CI 显式跑) | | vcan 集成 | vcan0 双 socket 收发,真实内核 CAN 栈 | Linux(`#[ignore]`,CI 显式跑) |
| 实车台架 | `chassis-spin` 四轮定速:链路/反馈、方向、转速稳态、过速保护、安全停机;`chassis-tune --backend bench` 逐次试验 + 重复性复验 | armv7 + slcan/SocketCAN(人工) | | 实车台架 | `spin chassis` 四轮定速:链路/反馈、方向、转速稳态、过速保护、安全停机;`chassis-tune --backend bench` 逐次试验 + 重复性复验 | armv7 + slcan/SocketCAN(人工) |
| 交叉检查 | `aarch64-unknown-linux-gnu` 全 workspace check;armv7 静态构建 `chassisd` | CI | | 交叉检查 | `aarch64-unknown-linux-gnu` 全 workspace check;armv7 静态构建 `chassisd` | CI |
### 13.2 每个阶段完成后的门禁(`just lint` + 以下) ### 13.2 每个阶段完成后的门禁(`just lint` + 以下)
@@ -660,7 +616,7 @@ just build-armv7 # cargo build --release -p chassisd --target armv7-unknown-
just deploy # 编译 + 推送 + mock 试运行(默认 root@192.168.2.2) just deploy # 编译 + 推送 + mock 试运行(默认 root@192.168.2.2)
``` ```
目标板实测:创龙 T153 / Ubuntu 22.04(armv7)。二进制静态链接,不依赖板端 libc。 目标板实测:创龙 T153 / Ubuntu 22.04(armv7)。发布二进制使用 musl 静态链接。
### 14.2 板端拉起 CAN ### 14.2 板端拉起 CAN
@@ -692,9 +648,8 @@ RestartSec=1
> (`ip -d link show can0` 可见),`chassisd` 侧表现为四电机反馈离线、强制零电流—— > (`ip -d link show can0` 可见),`chassisd` 侧表现为四电机反馈离线、强制零电流——
> **这正是安全设计的预期行为**,不是故障。 > **这正是安全设计的预期行为**,不是故障。
> >
> 调度策略:`ARCHITECTURE.md` 规划了 `SCHED_FIFO` + `mlockall`(对应 `rm-app::realtime`), > 调度策略:应用不调用 libc FFI。
> 本仓库目前**未实现**实时优先级提升,当前只依赖 1 kHz tokio 定时器 + > 应用不调用 libc FFI。`SCHED_FIFO` 与资源限制由 systemd 服务单元设置;std 程序不调用 `mlockall`。
> `MissedTickBehavior::Skip`。板端抖动实测与是否需要加固见第 15 节的抖动条目。
--- ---

Some files were not shown because too many files have changed in this diff Show More