Files
linux_sentry_chassis/crates/dji-esc-sim/src/scenario.rs
T

237 lines
7.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 场景定义:设定值曲线 + 采样节拍。
//!
//! 场景与具体被控对象解耦:同一场景可在 [`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);
}
}