refactor: 重构架构并整理仓库
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
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
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
@@ -10,32 +10,37 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# 可选:用 plotters 输出 best.svg(默认关闭,避免 CI 依赖系统字体与更长编译)。
|
||||
plot = ["dep:plotters"]
|
||||
plot = ['dep:plotters']
|
||||
|
||||
[dependencies]
|
||||
can-link = { workspace = true }
|
||||
chassis-bench = { workspace = true }
|
||||
bench = { workspace = true }
|
||||
can = { 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 }
|
||||
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 }
|
||||
tracing = { 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 }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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 接口必须报错");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
+1102
-58
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user