feat: 添加了自动超参数整定

This commit is contained in:
Horldsence
2026-09-23 00:42:26 +08:00
parent 7d87987e41
commit 680adae339
56 changed files with 13664 additions and 245 deletions
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "dji-esc-sim"
description = "C620/C610 plant simulation: friction/delay model, scenario harness, and async plant rig"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
can-link = { workspace = true }
motor-core = { workspace = true }
dji-esc = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
rand = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
+186
View File
@@ -0,0 +1,186 @@
//! 被控对象集成:用同一组参数的**抖动样本**表达模型不确定性。
//!
//! 单点模型整定出来的增益往往对增益 / 时间常数 / 延迟 / 摩擦高度敏感
//! (真车轮子之间、电量与温度变化都会漂移)。整定应针对一个**集成**做
//! 鲁棒优化,而不是针对一条理想曲线做最优化。
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
@@ -0,0 +1,258 @@
//! 离线场景评估:同步、无 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
@@ -0,0 +1,39 @@
//! 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
@@ -0,0 +1,361 @@
//! 被控对象模型:一阶转速 + 摩擦 / 延迟 / 负载。
//!
//! 电调坐标系(正电流 → 正转速),逐拍离散(`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
@@ -0,0 +1,147 @@
//! 异步 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
@@ -0,0 +1,236 @@
//! 场景定义:设定值曲线 + 采样节拍。
//!
//! 场景与具体被控对象解耦:同一场景可在 [`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
@@ -0,0 +1,230 @@
//! 试验轨迹:一次场景重放的全部采样。
/// 一条完整试验轨迹(各列等长,索引即采样序号)。
#[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);
}
}
+419
View File
@@ -0,0 +1,419 @@
//! 闭环集成测试:`EscBus` + 内存通道 + 一阶电机仿真。
//!
//! 全部可在任意平台(macOS / Linux / CI)运行,无需硬件。
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::time::Duration;
use dji_esc::{
BusOptions, CanFrame, CanLink, EscBus, EscBusError, EscConfig, EscFeedback,
encode_feedback_frame,
};
use dji_esc_sim::spawn_plant;
use motor_core::{Disable, EffortDrive, MotorFeedback, VelocityDrive, VelocityGains};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio::time::{MissedTickBehavior, interval, sleep, timeout};
/// 与 `PlantParams::gm3508` 增益精确匹配的速度环参数(ff = 16384 / 9254)。
fn tuned(esc_id: u8, direction: f32) -> EscConfig {
let mut cfg = EscConfig::gm3508(format!("m{esc_id}"), esc_id, direction);
cfg.kp = 8.0;
cfg.ki = 2.0;
cfg.kd = 0.0;
cfg.feedforward = 16384.0 / 9254.0;
cfg
}
/// 轮询直到条件满足或超时(毫秒,仿真时间)。
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()
}
/// 与 [`wait_until`] 相同,但每次轮询都重新下发 `target_rpm`。
///
/// **命令是租约**:驱动在 [`BusOptions::command_timeout`](默认 50ms)内未收到
/// 新命令就强制 0 输出。真实底盘循环每 1ms 重发一次,这里以 5ms 节拍模拟。
async fn hold_until<M: VelocityDrive>(
total_ms: u64,
motor: &M,
target_rpm: f32,
mut cond: impl FnMut() -> bool,
) -> bool {
let mut waited = 0u64;
loop {
motor.set_velocity(target_rpm); // 续租
if cond() {
return true;
}
if waited >= total_ms {
return false;
}
sleep(Duration::from_millis(5)).await;
waited += 5;
}
}
/// 保持 `target_rpm` 共 `ms` 毫秒(期间持续续租),仅推进仿真时间。
async fn hold<M: VelocityDrive>(motor: &M, target_rpm: f32, ms: u64) {
let _ = hold_until(ms, motor, target_rpm, || false).await;
}
/// 手动向总线注入一帧所有电机的反馈(新鲜度 100ms 内有效)。
async fn send_feedback(peer: &CanLink, esc_ids: &[u8]) {
for &id in esc_ids {
let fb = EscFeedback { angle_raw: 0, speed_rpm: 0, current_raw: 0, temperature_c: 30 };
peer.outgoing_tx.send(encode_feedback_frame(id, &fb)).await.unwrap();
}
}
async fn recv_command(peer: &mut CanLink) -> CanFrame {
timeout(Duration::from_millis(100), peer.incoming_rx.recv())
.await
.expect("timed out waiting for command frame")
.expect("link closed")
}
fn slot_current(frame: &CanFrame, slot: usize) -> i16 {
i16::from_be_bytes([frame.data[slot * 2], frame.data[slot * 2 + 1]])
}
// ---------------------------------------------------------------- 收敛性
#[tokio::test(start_paused = true)]
async fn velocity_loop_converges() {
let (bus_link, plant_link) = CanLink::pair(512);
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 motor = bus.motor(1).unwrap().clone();
motor.set_velocity(3000.0);
let ok = hold_until(2000, &motor, 3000.0, || motor.state().rotor_speed_rpm >= 2950.0).await;
assert!(ok, "not converged, rpm = {}", motor.state().rotor_speed_rpm);
// 稳态保持(速度无漂移、反馈持续流入);期间必须继续续租命令。
let count_before = motor.state().feedback_count;
hold(&motor, 3000.0, 300).await;
let st = motor.state();
assert!((2950.0..=3050.0).contains(&st.rotor_speed_rpm), "steady state drifted: {st:?}");
assert!(
st.feedback_count >= count_before + 280,
"feedback should keep flowing: {} → {}",
count_before,
st.feedback_count
);
plant.abort();
bus.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn reversed_motor_converges_in_physical_frame() {
// direction = -1:物理 +2000 rpm ⇔ 电调侧 -2000 rpm。
let (bus_link, plant_link) = CanLink::pair(512);
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 motor = bus.motor(2).unwrap().clone();
motor.set_velocity(2000.0);
let ok = hold_until(2000, &motor, 2000.0, || motor.state().rotor_speed_rpm >= 1950.0).await;
assert!(ok, "not converged, rpm = {}", motor.state().rotor_speed_rpm);
let st = motor.state();
assert!((1950.0..=2050.0).contains(&st.rotor_speed_rpm), "{st:?}");
// 物理转速为正 ⇒ 电调电流为负 ⇒ 物理电流 = dir × esc > 0。
assert!(st.effort_raw > 0, "{st:?}");
plant.abort();
bus.shutdown().await;
}
// ---------------------------------------------------------------- 安全
#[tokio::test(start_paused = true)]
async fn motor_without_feedback_commands_zero_current() {
let (bus_link, mut peer) = CanLink::pair(64);
// 不接 plant → 永远没有反馈。
let bus = EscBus::spawn(bus_link, vec![tuned(1, 1.0)], BusOptions::default()).unwrap();
bus.motor(1).unwrap().set_velocity(5000.0);
for _ in 0..5 {
let frame = recv_command(&mut peer).await;
assert_eq!(frame.id, 0x200);
assert_eq!(frame.data, [0; 8], "must send zero current while offline");
}
bus.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn current_limit_clamps_saturation() {
let (bus_link, mut peer) = CanLink::pair(64);
let mut cfg = tuned(1, 1.0);
cfg.current_limit = 5000;
let bus = EscBus::spawn(bus_link, vec![cfg], BusOptions::default()).unwrap();
send_feedback(&peer, &[1]).await;
bus.motor(1).unwrap().set_velocity(30000.0); // 不可达目标 → 深度饱和
sleep(Duration::from_millis(5)).await;
let mut saw_saturated = false;
for _ in 0..5 {
bus.motor(1).unwrap().set_velocity(30000.0); // 续租,避免读到租约到期后的 0
let frame = recv_command(&mut peer).await;
let raw = slot_current(&frame, 0);
assert!(raw <= 5000, "current {raw} exceeds limit 5000");
saw_saturated |= raw == 5000;
}
assert!(saw_saturated, "loop should reach the configured limit");
bus.shutdown().await;
}
// ---------------------------------------------------------------- 命令租约
/// 反馈喂食器:每 5ms 注入一帧所有电机的反馈,让电机保持"在线",
/// 从而把「租约到期归零」与「离线保护归零」区分开。
fn spawn_feedback(yield_tx: mpsc::Sender<CanFrame>, esc_ids: Vec<u8>) -> JoinHandle<()> {
tokio::spawn(async move {
let mut ticker = interval(Duration::from_millis(5));
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let fb = EscFeedback { angle_raw: 0, speed_rpm: 0, current_raw: 0, temperature_c: 30 };
loop {
ticker.tick().await;
for &id in &esc_ids {
if yield_tx.send(encode_feedback_frame(id, &fb)).await.is_err() {
return;
}
}
}
})
}
/// 一次性 `set_velocity` 在租约内出力,租约到期后必须自动归零;
/// 重新下发又能恢复出力——这就是"命令是租约"契约的可执行规范。
#[tokio::test(start_paused = true)]
async fn command_lease_expires_and_renews() {
let (bus_link, mut peer) = CanLink::pair(256);
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 motor = bus.motor(1).unwrap().clone();
// 1) 下发后 30ms 内(< 50ms 租约)应看到非零电流。
motor.set_velocity(3000.0);
let mut live = false;
for _ in 0..30 {
if slot_current(&recv_command(&mut peer).await, 0) != 0 {
live = true;
break;
}
}
assert!(live, "租约内应输出非零电流");
// 2) 沉默 200ms(> 50ms 租约)→ 必须强制归零。
sleep(Duration::from_millis(200)).await;
// 排空沉默期间积压的帧:其中前段仍带着租约到期前的旧命令。
while peer.incoming_rx.try_recv().is_ok() {}
sleep(Duration::from_millis(10)).await;
for _ in 0..5 {
let raw = slot_current(&recv_command(&mut peer).await, 0);
assert_eq!(raw, 0, "租约到期后必须归零,实测 {raw}");
}
// 3) 续租 → 恢复出力。
motor.set_velocity(3000.0);
let mut renewed = false;
for _ in 0..30 {
if slot_current(&recv_command(&mut peer).await, 0) != 0 {
renewed = true;
break;
}
}
assert!(renewed, "续租后应恢复非零电流");
feeder.abort();
bus.shutdown().await;
}
// ---------------------------------------------------------------- 组帧与路由
#[tokio::test(start_paused = true)]
async fn current_commands_route_to_group_slots() {
let (bus_link, mut peer) = CanLink::pair(256);
let configs = (1..=5).map(|id| tuned(id, 1.0)).collect();
let bus = EscBus::spawn(bus_link, configs, BusOptions::default()).unwrap();
bus.motor(3).unwrap().set_effort_raw(1234);
bus.motor(5).unwrap().set_effort_raw(-567);
send_feedback(&peer, &(1..=5).collect::<Vec<_>>()).await;
sleep(Duration::from_millis(5)).await;
let mut saw_200 = false;
let mut saw_1ff = false;
for _ in 0..20 {
// 续租两条直发命令,避免读到租约到期后的 0 槽。
bus.motor(3).unwrap().set_effort_raw(1234);
bus.motor(5).unwrap().set_effort_raw(-567);
let frame = recv_command(&mut peer).await;
match frame.id {
0x200 if !saw_200 => {
assert_eq!(frame.data, [0, 0, 0, 0, 0x04, 0xD2, 0, 0], "esc3 slot = 1234");
saw_200 = true;
}
0x1FF if !saw_1ff => {
// -567 = 0xFDC9(i16 补码,大端)。
assert_eq!(frame.data, [0xFD, 0xC9, 0, 0, 0, 0, 0, 0], "esc5 slot = -567");
saw_1ff = true;
}
_ => {}
}
if saw_200 && saw_1ff {
break;
}
}
assert!(saw_200, "no 0x200 frame observed");
assert!(saw_1ff, "no 0x1FF frame observed");
// disable 后槽位清零。
bus.motor(3).unwrap().disable();
let zeroed = wait_until(200, || {
try_find_frame(&mut peer, |frame| frame.id == 0x200 && slot_current(frame, 2) == 0)
})
.await;
assert!(zeroed, "esc3 slot should return to 0 after disable");
bus.shutdown().await;
}
/// 非阻塞地扫一眼通道里已有的帧。
fn try_find_frame(peer: &mut CanLink, pred: impl Fn(&CanFrame) -> bool) -> bool {
while let Ok(frame) = peer.incoming_rx.try_recv() {
if pred(&frame) {
return true;
}
}
false
}
// ---------------------------------------------------------------- 反馈 → 状态
#[tokio::test]
async fn feedback_updates_state_with_direction() {
let (bus_link, peer) = CanLink::pair(64);
let bus = EscBus::spawn(bus_link, vec![tuned(2, -1.0)], BusOptions::default()).unwrap();
// 两帧正向跨圈:8000 → 100,电调侧圈数 ≈ 1.0122。
let fb = EscFeedback { angle_raw: 8000, speed_rpm: 600, current_raw: -300, temperature_c: 47 };
peer.outgoing_tx.send(encode_feedback_frame(2, &fb)).await.unwrap();
let fb2 = EscFeedback { angle_raw: 100, ..fb };
peer.outgoing_tx.send(encode_feedback_frame(2, &fb2)).await.unwrap();
sleep(Duration::from_millis(50)).await;
let st = bus.motor(2).unwrap().state();
assert_eq!(st.rotor_angle_raw, 100);
assert!((st.rotor_speed_rpm + 600.0).abs() < 1e-3); // direction = -1
assert_eq!(st.effort_raw, 300);
assert_eq!(st.temperature_c, 47);
assert_eq!(st.feedback_count, 2);
let esc_rev = 1.0 + 100.0 / 8192.0;
assert!(
(st.rotor_revolutions + esc_rev).abs() < 1e-3,
"revolutions = {}",
st.rotor_revolutions
);
// 输出轴换算由 `OutputShaft` 负责(见 motor-core gearbox 单测)。
bus.shutdown().await;
}
// ---------------------------------------------------------------- 生命周期
#[tokio::test(start_paused = true)]
async fn shutdown_stops_tasks_and_closes_link() {
let (bus_link, mut peer) = CanLink::pair(128);
// 不接 plant,直接验证任务生命周期。
let bus = EscBus::spawn(bus_link, vec![tuned(1, 1.0)], BusOptions::default()).unwrap();
bus.motor(1).unwrap().set_velocity(1000.0);
sleep(Duration::from_millis(20)).await;
// 有命令帧在流动。
assert!(peer.incoming_rx.recv().await.is_some());
bus.shutdown().await;
// 排空残留缓冲帧后应立即得到 None(发送端已全部退出);
// 若任务仍在运行,帧会持续流入并触发超时。
loop {
match timeout(Duration::from_millis(100), peer.incoming_rx.recv()).await {
Ok(None) => break,
Ok(Some(_)) => {} // 排空残留缓冲帧
Err(elapsed) => panic!("bus tasks still running after shutdown (waited {elapsed:?})"),
}
}
}
#[test]
fn spawn_rejects_bad_configs() {
let link = || CanLink::pair(8).0;
assert_eq!(
EscBus::spawn(link(), vec![], 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))
);
let mut bad = tuned(1, 1.0);
bad.direction = 0.0;
let err = EscBus::spawn(link(), vec![bad], BusOptions::default()).err();
assert!(
matches!(err, Some(EscBusError::InvalidConfig { .. })),
"expected InvalidConfig, got {err:?}"
);
}
// ---------------------------------------------------------------- 增益热更新
/// 自动整定的真机后端靠这条路径逐次试验:不重启总线,只换增益。
/// 契约:① 读取即得到最新增益;② 换增益后控制器**状态复位**(无旧积分残留);
/// ③ 限幅等安全字段不受影响。
#[tokio::test(start_paused = true)]
async fn velocity_gains_can_be_hot_swapped() {
let (bus_link, plant_link) = CanLink::pair(256);
let plant = spawn_plant(plant_link, &[1], Duration::from_millis(1));
let cfg = tuned(1, 1.0);
let limit = i32::from(cfg.current_limit);
let bus = EscBus::spawn(bus_link, vec![cfg.clone()], BusOptions::default()).unwrap();
let motor = bus.motor(1).unwrap().clone();
// ① 初始增益来自 EscConfig(含前馈)。
assert_eq!(motor.velocity_gains(), cfg.velocity_gains());
// 让旧增益的积分先积累起来(大静差 + 深度饱和)。
motor.set_velocity(9000.0);
hold(&motor, 9000.0, 200).await;
let stale = motor.velocity_gains();
// ② 热更新:控制器被重建。
let swapped = VelocityGains::new(1.0, 0.0, 0.0, 0.0);
motor.set_velocity_gains(swapped);
assert_eq!(motor.velocity_gains(), swapped);
assert_ne!(motor.velocity_gains(), stale);
// 新增益生效:误差 e 时输出 = kp·e = e(无前馈、无积分),并仍被限幅夹住。
motor.set_velocity(30_000.0);
let saturated =
hold_until(500, &motor, 30_000.0, || i32::from(motor.state().effort_raw) >= limit).await;
assert!(saturated, "effort = {} 未达到限幅 {}", motor.state().effort_raw, limit);
// ③ 限幅仍是启动时的 current_limit:热更新不得放宽安全边界。
let effort = i32::from(motor.state().effort_raw);
assert_eq!(effort, limit, "饱和值必须正好是 current_limit");
assert_eq!(motor.velocity_gains(), swapped, "热更新后增益保持不变");
plant.abort();
bus.shutdown().await;
}
@@ -0,0 +1,99 @@
//! 语义一致性:同步评估 harness 与异步 `EscBus` 闭环必须给出同一个答案。
//!
//! 自动整定跑在 [`dji_esc_sim::simulate`](同步、无通道)上,而实车跑的是
//! `EscBus` 的 1 kHz 发送循环。若两者的限幅 / 物理帧 / 控制器语义漂移,
//! 整定出来的增益就只是"对仿真有效"。本测试把两条路径放在同一组参数与
//! 同一条场景下比较稳态与 effort。
//!
//! 允许的差异来自**离散化细节**(异步侧每拍实测 `dt`、反馈比命令晚一拍),
//! 因此比较的是稳态量级而不是逐点相等。
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::time::Duration;
use dji_esc::{BusOptions, CanLink, EscBus, EscConfig};
use dji_esc_sim::{LoopLimits, PlantParams, Scenario, simulate, spawn_plant};
use motor_core::{MotorFeedback, VelocityDrive, VelocityGains};
use tokio::time::sleep;
/// 与 `PlantParams::gm3508` 匹配的增益(ff = 16384 / 9254)。
const GAINS: VelocityGains = VelocityGains::new(8.0, 2.0, 0.0, 16384.0 / 9254.0);
const TARGET_RPM: f32 = 3000.0;
const HOLD_MS: u64 = 2000;
/// 稳态采样窗口(虚拟时间,毫秒)。
const SAMPLE_FROM_MS: u64 = 1500;
/// 同步路径:最后 500 ms 的稳态统计。
fn sync_steady_state() -> (f32, f32) {
let scenario = Scenario::step(TARGET_RPM, HOLD_MS as f32 / 1000.0);
let trace = simulate(GAINS, &PlantParams::gm3508(), &scenario, LoopLimits::gm3508())
.expect("simulation must succeed");
let window = trace.window(SAMPLE_FROM_MS as f32 / 1000.0, HOLD_MS as f32 / 1000.0);
let mean_effort =
window.effort_raw().iter().map(|&raw| f32::from(raw)).sum::<f32>() / window.len() as f32;
(window.mean_rpm(), mean_effort)
}
/// 异步路径:`EscBus` + 假电调,逐拍续租直到 2 秒(虚拟时间)。
async fn async_steady_state() -> (f32, f32) {
let (bus_link, plant_link) = CanLink::pair(512);
let plant = spawn_plant(plant_link, &[1], Duration::from_millis(1));
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;
let bus = EscBus::spawn(bus_link, vec![cfg], BusOptions::default()).expect("bus must start");
let motor = bus.motor(1).expect("motor 1 exists").clone();
// 命令是租约:逐拍重发(5 ms 节拍模拟 1 kHz 控制循环)。
let mut waited = 0u64;
let mut effort_sum = 0.0f32;
let mut rpm_sum = 0.0f32;
let mut samples = 0u32;
while waited < HOLD_MS {
motor.set_velocity(TARGET_RPM);
if waited >= SAMPLE_FROM_MS {
let state = motor.state();
effort_sum += f32::from(state.effort_raw);
rpm_sum += state.rotor_speed_rpm;
samples += 1;
}
sleep(Duration::from_millis(5)).await;
waited += 5;
}
plant.abort();
bus.shutdown().await;
(rpm_sum / samples as f32, effort_sum / samples as f32)
}
#[tokio::test(start_paused = true)]
async fn synchronous_harness_matches_async_closed_loop() {
let (sync_rpm, sync_effort) = sync_steady_state();
let (async_rpm, async_effort) = async_steady_state().await;
// 稳态转速:两条路径都应收敛到目标附近(理想模型 + 精确前馈 → 稳态无差)。
let rpm_tolerance = 0.05 * TARGET_RPM;
assert!(
(sync_rpm - async_rpm).abs() < rpm_tolerance,
"steady rpm mismatch: sync {sync_rpm}, async {async_rpm}"
);
assert!(
(sync_rpm - TARGET_RPM).abs() < rpm_tolerance,
"sync steady state off target: {sync_rpm}"
);
assert!(
(async_rpm - TARGET_RPM).abs() < rpm_tolerance,
"async steady state off target: {async_rpm}"
);
// effort:前馈主导,两条路径量级必须接近(±20% 或 ±300 raw 取大者)。
let effort_tolerance = (0.2 * sync_effort.abs()).max(300.0);
assert!(
(sync_effort - async_effort).abs() < effort_tolerance,
"steady effort mismatch: sync {sync_effort}, async {async_effort}"
);
}