Files
linux_sentry_chassis/crates/chassis-tune/src/main.rs
T

2826 lines
100 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::sync::Mutex;
pub mod report {
//! 报告与产物写出: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 experiment::{Scenario, Trace};
use serde::Serialize;
use sim_harness::LoopLimits;
use experiment::StepMetrics;
use tuning::gains::GainSpace;
use tuning::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::tune::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,
tuning::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: tuning::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, "| 场景 | `{}` |", tuning::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::SimEvaluator;
use motor_model::PlantParams;
use sim_harness::{LoopLimits, PlantEnsemble};
use tuning::Evaluator;
use tuning::cost::{Objective, Weights};
use tuning::gains::GainSpace;
use tuning::search::{SearchConfig, sweep};
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 =
sim_harness::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));
}
}
}
#[cfg(feature = "plot")]
pub mod plot {
//! 可选(`--features plot`):把最优步响应画成 SVG。
//!
//! 为什么是 SVG 而不是 PNG:矢量图在任何缩放级别都能看清起动段的细节,
//! 且不需要位图编码器(依赖更小)。默认关闭该 feature——绘图不是整定的
//! 必要条件,CSV 已经给了全部数据。
use std::path::Path;
use experiment::{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 experiment::SetpointProfile;
use motor_control::VelocityGains;
use motor_model::PlantParams;
use sim_harness::{LoopLimits, simulate};
#[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);
}
}
}
// `chassis-tune`:底盘速度环 PID 自动整定命令行工具。
//
// ```sh
// # 0) 看预设文件里有哪些工况预设(只读,不搜索)
// cargo run -p chassis-tune -- presets
//
// # 1) 仿真搜索(默认;不碰硬件,可反复跑)
// cargo run --release -p chassis-tune -- sweep --samples 512 --out-dir target/tune
//
// # 1b) 用预设跑(超参数进版本管理,见 config/tune.toml)
// cargo run --release -p chassis-tune -- sweep \
// --preset config/tune.toml --preset-name bench_loaded
//
// # 2) 复算一组给定增益(CI / 回归用,按阈值给退出码)
// cargo run -p chassis-tune -- validate --gains 2,10,0,0
//
// # 3) 台架复验(**轮子会转**:必须架空或整车固定,急停可及)
// cargo run --release -p chassis-tune -- sweep --backend bench \
// --device /dev/ttyACM0 --esc-id 1 --samples 24 --top-k 3 \
// --i-know-the-wheel-spins
// ```
//
// # 超参数的优先级
//
// `内置默认 < 预设文件(`--preset` + `--preset-name`)< 显式命令行参数`。
// 机械 / 安全参数(`--current-limit`、`--direction`、`--gear-ratio`、电调 ID)
// 不参与这条链:它们只来自 `--config`(`chassis.toml`)或显式 CLI,
// 预设文件**不能**覆盖它们。
//
// 退出码:最优解满足验收阈值(`--max-overshoot` / `--max-ss-error`)为 0,
// 否则 1;参数 / IO 错误为 2。
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Duration;
use self::report::{ReportContext, write_reports};
use anyhow::{Context as _, Result, bail};
use bench::CanSpeed;
use bench::hil::{BenchConfig, BenchEvaluator as BenchRunner, open_link};
use bench::safety::SafetyEnvelope;
use chassis_config::Config;
use clap::{Args as ClapArgs, Parser, Subcommand, ValueEnum};
use esc_bus::EscConfig;
use experiment::SetpointProfile;
use indicatif::ProgressBar;
use motor_control::VelocityGains;
use motor_protocol::EscModel;
use serde::Serialize;
use sim_harness::{LoopLimits, PlantEnsemble};
use tuning::{
AcceptanceSpec, BackendSpec, BenchEnvelope, BenchSpec, CostSpec, DEFAULT_PRESET_PATH,
Evaluator, Objective, PlantSpec, Preset, PresetFile, ProfileSpec, RangeSpec, Ranked, ScaleSpec,
ScenarioSpec, Score, SearchSpec, SimSpec, SolverSpec, SpaceSpec, Tuning, Verdict, parse_axis,
sweep,
};
/// 子命令。
#[derive(Debug, Subcommand)]
enum Command {
/// 分层粗扫 + 多起点精修(默认动作)
Sweep(SweepArgs),
/// 只复算一组给定增益的指标(不搜索)
Validate(ValidateArgs),
/// 列出预设文件里的超参数预设(只读)
Presets(PresetsArgs),
}
/// 公共参数(仿真与真机共用)。
///
/// 超参数(参数空间、场景、权重、阈值、搜索预算、被控对象、安全包络)都可以缺省:
/// 缺省时按 **内置默认 → 预设文件 → 显式 CLI** 解算,见模块文档与
/// `tuning::preset`。这里的 `Option` 正是"用户是否显式给了"的载体。
#[derive(Debug, Clone, ClapArgs)]
struct CommonArgs {
/// 评估后端:`sim`(离线)或 `bench`(真机台架)
#[arg(long, value_enum)]
backend: Option<BackendArg>,
// ---------------- 超参数预设 ----------------
/// 超参数预设文件(TOML)。缺省时若给了 `--preset-name` 则用 `config/tune.toml`
#[arg(long, value_name = "FILE")]
preset: Option<PathBuf>,
/// 预设名(缺省取文件里的 `default_preset`)
#[arg(long, value_name = "NAME")]
preset_name: Option<String>,
// ---------------- 参数空间(覆盖预设) ----------------
/// kp 范围,如 `0.5..30`(内置默认 `0.5..30`)
#[arg(long, value_name = "MIN..MAX")]
kp: Option<String>,
/// ki 范围,如 `0.1..100`(内置默认 `0.1..100`)
#[arg(long, value_name = "MIN..MAX")]
ki: Option<String>,
/// kd 范围(内置默认 `0.01..3`)
#[arg(long, value_name = "MIN..MAX")]
kd: Option<String>,
/// 前馈范围(raw/rpm;内置默认 `0.01..2.5`)
#[arg(long, value_name = "MIN..MAX")]
feedforward: Option<String>,
/// kp 刻度(内置默认 `log`)
#[arg(long, value_enum)]
kp_scale: Option<ScaleArg>,
/// ki 刻度(`log0` 允许精确取 0;内置默认 `log0`)
#[arg(long, value_enum)]
ki_scale: Option<ScaleArg>,
/// kd 刻度(内置默认 `log0`)
#[arg(long, value_enum)]
kd_scale: Option<ScaleArg>,
/// 前馈刻度(内置默认 `log0`)
#[arg(long, value_enum)]
feedforward_scale: Option<ScaleArg>,
// ---------------- 场景(覆盖预设) ----------------
/// 目标转子转速(rpm;内置默认 1500)
#[arg(long)]
target_rpm: Option<f32>,
/// 场景类型(内置默认 `ramp-and-hold`)
#[arg(long, value_enum)]
scenario: Option<ScenarioArg>,
/// 斜坡时间(毫秒;内置默认 200)
#[arg(long)]
ramp_ms: Option<f32>,
/// 保持时间(毫秒;内置默认 1800)
#[arg(long)]
hold_ms: Option<f32>,
/// 阶梯级数(`--scenario staircase`;内置默认 4)
#[arg(long)]
stair_levels: Option<u32>,
/// 采样 / 控制节拍(微秒;内置默认 1000)
#[arg(long)]
dt_us: Option<f32>,
/// 电流限幅(raw;同时是 PID 输出钳位点)。缺省取 `--config` 里该电机的值,否则 12000
#[arg(long)]
current_limit: Option<i16>,
/// 电调型号。缺省取 `--config` 里该电机的值,否则 C620
#[arg(long, value_enum)]
model: Option<ModelArg>,
/// 现有 `chassis.toml`:用作机械 / 安全参数的来源(电流限幅、电调 ID、方向、减速比)
#[arg(long)]
config: Option<PathBuf>,
/// 把整定结果写回一份配置(需同时给 `--config`;只改写 kp/ki/kd/feedforward)
#[arg(long)]
write_config: Option<PathBuf>,
// ---------------- 成本权重(覆盖预设) ----------------
/// 调节时间权重(内置默认 1.0)
#[arg(long)]
w_settle: Option<f64>,
/// 超调权重(内置默认 1.0)
#[arg(long)]
w_overshoot: Option<f64>,
/// IAE 权重(内置默认 0.5)
#[arg(long)]
w_iae: Option<f64>,
/// 电流(RMS)权重(内置默认 0.1)
#[arg(long)]
w_effort: Option<f64>,
// ---------------- 验收阈值(覆盖预设) ----------------
/// 允许的最大超调(%;内置默认 20)
#[arg(long)]
max_overshoot: Option<f32>,
/// 允许的最大稳态误差(%;内置默认 5)
#[arg(long)]
max_ss_error: Option<f32>,
// ---------------- 搜索(覆盖预设) ----------------
/// 随机种子(同 seed 结果逐位一致;内置默认 20260101)
#[arg(long)]
seed: Option<u64>,
/// 粗扫样本数(内置默认 512)
#[arg(long)]
samples: Option<usize>,
/// 进入精修的种子数(内置默认 8)
#[arg(long)]
top_k: Option<usize>,
/// 精修求解器(内置默认 `nelder-mead`)
#[arg(long, value_enum)]
solver: Option<SolverArg>,
/// 每个起点的最大迭代数(内置默认 200)
#[arg(long)]
max_iters: Option<usize>,
/// PSO 粒子数(`--solver pso`;内置默认 24)
#[arg(long)]
particles: Option<usize>,
/// 评估预算上限(内置默认 4000)
#[arg(long)]
max_evals: Option<u64>,
/// 线程数(0 = rayon 默认;真机后端强制 1;内置默认 0)
#[arg(long)]
threads: Option<usize>,
// ---------------- 真机安全包络(覆盖预设) ----------------
/// 过速保护上限(rpm;内置默认 3000)
#[arg(long)]
max_rpm: Option<f32>,
/// 温度保护上限(℃;内置默认 70)
#[arg(long)]
max_temp_c: Option<i8>,
/// 单次试验超时(毫秒;内置默认 6000)
#[arg(long)]
trial_timeout_ms: Option<u64>,
/// 试验之间的静默期(毫秒;内置默认 500)
#[arg(long)]
between_trials_ms: Option<u64>,
/// 启动时等待反馈的超时(毫秒;内置默认 5000)
#[arg(long)]
startup_timeout_ms: Option<u64>,
/// 安全事件后继续搜索(默认立即收工)
#[arg(long)]
continue_on_abort: bool,
/// 最优增益的复验次数(写入 hil-validation.csv;内置默认 3)
#[arg(long)]
validate_runs: Option<usize>,
// ---------------- 输出 ----------------
/// 产物目录
#[arg(long, default_value = "target/chassis-tune")]
out_dir: PathBuf,
/// 目标轮名(写进 best.toml 注释与报告)
#[arg(long, default_value = "front_left")]
motor: String,
/// 把 JSON 摘要打印到 stdout
#[arg(long)]
json: bool,
#[command(flatten)]
sim: SimArgs,
#[command(flatten)]
bench: BenchArgs,
}
/// 仿真后端参数(覆盖预设)。
#[derive(Debug, Clone, ClapArgs)]
struct SimArgs {
/// 被控对象模型(内置默认 `gm3508-bench`)
#[arg(long, value_enum)]
plant: Option<PlantArg>,
/// 集成成员数(域随机化;1 = 只用名义模型;内置默认 8)
#[arg(long)]
ensemble: Option<usize>,
/// 鲁棒权重:0 = 只看均值,1 = 只看最坏成员(内置默认 0.5)
#[arg(long)]
robust_weight: Option<f64>,
/// 抖动幅度,如 `gain=0.2,tau=0.3,delay=3,friction=0.5`(内置默认同左)
#[arg(long)]
jitter: Option<String>,
}
/// 真机接线参数(**不参与搜索,也不受预设影响**)。
#[derive(Debug, Clone, ClapArgs)]
struct BenchArgs {
/// slcan 串口设备
#[arg(long, default_value = "/dev/ttyACM0")]
device: String,
/// SocketCAN 接口名(给了它就走 SocketCAN,忽略 --device)
#[arg(long)]
interface: Option<String>,
/// 串口波特率
#[arg(long, default_value_t = 2_000_000)]
serial_baud: u32,
/// CAN 波特率档位
#[arg(long, value_enum, default_value_t = CanSpeedArg::M1)]
can_speed: CanSpeedArg,
/// 目标电调 ID(缺省取 `--config`,否则 1)
#[arg(long)]
esc_id: Option<u8>,
/// 安装方向 +1 / -1(缺省取 `--config`,否则 1)
#[arg(long, allow_hyphen_values = true)]
direction: Option<f32>,
/// 减速比(转子 : 输出轴;缺省取 `--config`,否则 19.203)
#[arg(long)]
gear_ratio: Option<f32>,
/// **明确确认**:轮子会真的转起来(架空或整车固定、急停可及)
#[arg(long)]
i_know_the_wheel_spins: bool,
}
#[derive(Debug, ClapArgs)]
struct SweepArgs {
#[command(flatten)]
common: CommonArgs,
}
#[derive(Debug, ClapArgs)]
struct ValidateArgs {
#[command(flatten)]
common: CommonArgs,
/// 四个增益,如 `2,10,0,0`(kp,ki,kd,feedforward)
#[arg(long)]
gains: String,
}
#[derive(Debug, ClapArgs)]
struct PresetsArgs {
/// 预设文件(缺省 `config/tune.toml`)
#[arg(long, default_value = DEFAULT_PRESET_PATH)]
preset: PathBuf,
}
#[derive(Debug, Parser)]
#[command(
name = "chassis-tune",
version,
about = "底盘速度环 PID 自动整定(参数空间搜索:仿真 / 真机台架)"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
// ---------------------------------------------------------------- 枚举参数
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum BackendArg {
/// 离线被控对象仿真
Sim,
/// 真机台架(会驱动电机)
Bench,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum ScaleArg {
/// 线性
Linear,
/// 对数(恒正)
Log,
/// 以一定概率取 0,否则对数
Log0,
}
impl From<ScaleArg> for ScaleSpec {
fn from(value: ScaleArg) -> Self {
match value {
ScaleArg::Linear => Self::Linear,
ScaleArg::Log => Self::Log,
ScaleArg::Log0 => Self::Log0,
}
}
}
impl From<BackendArg> for BackendSpec {
fn from(value: BackendArg) -> Self {
match value {
BackendArg::Sim => Self::Sim,
BackendArg::Bench => Self::Bench,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum ScenarioArg {
/// 阶跃
Step,
/// 斜坡 + 保持
RampAndHold,
/// 阶梯
Staircase,
/// 升 → 保持 → 降 → 保持
UpDown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum SolverArg {
/// Nelder-Mead(默认,无导数)
NelderMead,
/// 粒子群
Pso,
}
impl From<SolverArg> for SolverSpec {
fn from(value: SolverArg) -> Self {
match value {
SolverArg::NelderMead => Self::NelderMead,
SolverArg::Pso => Self::Pso,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum PlantArg {
/// 理想一阶模型(无摩擦、无延迟)
Gm3508,
/// 台架量级:静 / 库仑摩擦 + 2 拍延迟
Gm3508Bench,
}
impl From<PlantArg> for PlantSpec {
fn from(value: PlantArg) -> Self {
match value {
PlantArg::Gm3508 => Self::Gm3508,
PlantArg::Gm3508Bench => Self::Gm3508Bench,
}
}
}
impl From<ScenarioArg> for ProfileSpec {
fn from(value: ScenarioArg) -> Self {
match value {
ScenarioArg::Step => Self::Step,
ScenarioArg::RampAndHold => Self::RampAndHold,
ScenarioArg::Staircase => Self::Staircase,
ScenarioArg::UpDown => Self::UpDown,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum ModelArg {
/// C620(±16384 ↔ ±20 A)
C620,
/// C610(±10000 ↔ ±10 A)
C610,
}
impl From<ModelArg> for EscModel {
fn from(value: ModelArg) -> Self {
match value {
ModelArg::C620 => Self::C620,
ModelArg::C610 => Self::C610,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CanSpeedArg {
/// 1 Mbps
#[value(name = "1m")]
M1,
/// 500 kbps
#[value(name = "500k")]
K500,
}
impl From<CanSpeedArg> for CanSpeed {
fn from(value: CanSpeedArg) -> Self {
match value {
CanSpeedArg::M1 => Self::M1,
CanSpeedArg::K500 => Self::K500,
}
}
}
// ---------------------------------------------------------------- 入口
fn main() -> ExitCode {
init_tracing();
let cli = Cli::parse();
match run(cli) {
Ok(code) => code,
Err(error) => {
eprintln!("✗ {error:#}");
ExitCode::from(2)
}
}
}
fn init_tracing() {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
}
fn run(cli: Cli) -> Result<ExitCode> {
match cli.command {
Command::Sweep(args) => sweep_cmd(&args.common.resolve()?),
Command::Validate(args) => validate_cmd(&args.common.resolve()?, &args.gains),
Command::Presets(args) => presets_cmd(&args),
}
}
/// 解算后的运行设置:**已校验**的超参数 + 本次运行的透传项。
///
/// 下游只面对这个结构,因此"超参数是从哪来的"(内置默认 / 预设 / CLI)
/// 不再散落在每个使用点,而是集中在 [`CommonArgs::resolve`] 一处。
#[derive(Debug)]
struct Settings {
// ---- 透传:机械 / 安全与输出(不参与超参数优先级链) ----
/// `chassis.toml` 路径。
config: Option<PathBuf>,
/// 整定结果写回路径。
write_config: Option<PathBuf>,
/// 产物目录。
out_dir: PathBuf,
/// 目标轮名。
motor: String,
/// 打印 JSON 摘要。
json: bool,
/// 电流限幅覆盖(缺省取 `chassis.toml`,再缺省 12000)。
current_limit: Option<i16>,
/// 电调型号覆盖。
model: Option<ModelArg>,
/// 真机接线参数。
bench: BenchArgs,
/// 超参数来源描述(打印与报告用)。
source: String,
// ---- 超参数(已解析 / 已校验) ----
/// 解算后的超参数。
tuning: Tuning,
/// 真机安全包络。
envelope: BenchEnvelope,
}
impl CommonArgs {
/// 解算超参数:**内置默认 → 预设文件 → 显式 CLI**。
///
/// # Errors
///
/// 预设文件读取 / 解析失败、预设名不存在、区间 / 阈值 / 预算非法、
/// 或预设路径缺失时返回错误。
fn resolve(&self) -> Result<Settings> {
let (file, prefix) = preset_source(
self.preset.as_ref(),
self.preset_name.as_deref(),
Path::new(DEFAULT_PRESET_PATH),
)?;
let (name, selected) = file.select_named(self.preset_name.as_deref())?;
let mut merged = Preset::builtin().overlay(selected).overlay(&self.range_overlay()?);
// 刻度与抖动允许"只改这一项",因此不放进 range_overlay,而是在合并后定点覆盖。
apply_scales(&mut merged, self);
apply_jitter(&mut merged, self)?;
let source =
if prefix.is_empty() { "内置默认".to_owned() } else { format!("{prefix}{name}") };
let tuning = merged.to_tuning().with_context(|| format!("解算预设 '{source}' 失败"))?;
let envelope = merged
.bench_envelope()
.with_context(|| format!("解算预设 '{source}' 的安全包络失败"))?;
Ok(Settings {
config: self.config.clone(),
write_config: self.write_config.clone(),
out_dir: self.out_dir.clone(),
motor: self.motor.clone(),
json: self.json,
current_limit: self.current_limit,
model: self.model,
bench: self.bench.clone(),
source,
tuning,
envelope,
})
}
/// 只含"显式给出的区间 / 后端 / 场景 / 权重 / 阈值 / 搜索 / 被控对象"的覆盖层。
///
/// 刻度(`--kp-scale` 等)与 `--jitter` 不在这里:它们可以单独给出,
/// 由 [`apply_scales`] / [`apply_jitter`] 在合并后覆盖。
///
/// # Errors
///
/// `--kp` 这类区间文本无法解析时返回错误。
fn range_overlay(&self) -> Result<Preset> {
let space = SpaceSpec {
kp: range(self.kp.as_deref(), "kp")?,
ki: range(self.ki.as_deref(), "ki")?,
kd: range(self.kd.as_deref(), "kd")?,
feedforward: range(self.feedforward.as_deref(), "feedforward")?,
};
let scenario = ScenarioSpec {
profile: self.scenario.map(Into::into),
target_rpm: self.target_rpm,
ramp_ms: self.ramp_ms,
hold_ms: self.hold_ms,
stair_levels: self.stair_levels,
dt_us: self.dt_us,
};
let cost = CostSpec {
settle: self.w_settle,
overshoot: self.w_overshoot,
iae: self.w_iae,
effort: self.w_effort,
};
let acceptance = AcceptanceSpec {
max_overshoot_pct: self.max_overshoot,
max_ss_error_pct: self.max_ss_error,
};
let search = SearchSpec {
seed: self.seed,
samples: self.samples,
top_k: self.top_k,
solver: self.solver.map(Into::into),
max_iters: self.max_iters,
particles: self.particles,
max_evals: self.max_evals,
threads: self.threads,
};
let sim = SimSpec {
plant: self.sim.plant.map(Into::into),
ensemble: self.sim.ensemble,
robust_weight: self.sim.robust_weight,
..SimSpec::default()
};
let bench = BenchSpec {
max_rpm: self.max_rpm,
max_temp_c: self.max_temp_c,
trial_timeout_ms: self.trial_timeout_ms,
between_trials_ms: self.between_trials_ms,
startup_timeout_ms: self.startup_timeout_ms,
// `--continue-on-abort` 是布尔开关:只在给出时为 true,不去覆盖预设的 true。
continue_on_abort: self.continue_on_abort.then_some(true),
validate_runs: self.validate_runs,
};
Ok(Preset {
backend: self.backend.map(Into::into),
space,
scenario,
cost,
acceptance,
search,
sim,
bench,
..Preset::default()
})
}
}
/// 选择预设来源,返回(预设库, 来源前缀)。
///
/// 顺序:显式 `--preset` > 给了 `--preset-name` 时的 `default_path` > 内置库。
/// `default_path` 作为参数传入(而不是直接读常量),这样"只给 `--preset-name`"
/// 的回落逻辑可以在不依赖当前工作目录的情况下被测试。
///
/// # Errors
///
/// 预设文件不存在 / 无法解析,或"给了名字却找不到默认文件"时返回错误。
fn preset_source(
explicit: Option<&PathBuf>,
preset_name: Option<&str>,
default_path: &Path,
) -> Result<(PresetFile, String)> {
let path = match explicit {
Some(path) => path.clone(),
None if preset_name.is_some() => {
if !default_path.exists() {
bail!(
"给了 --preset-name 但没给 --preset,且默认路径 {} 不存在\n \
请显式指定:--preset {} --preset-name <NAME>",
default_path.display(),
default_path.display()
);
}
default_path.to_path_buf()
}
None => return Ok((PresetFile::builtin(), String::new())),
};
let file =
PresetFile::load(&path).with_context(|| format!("加载预设文件 {}", path.display()))?;
Ok((file, format!("{}#", path.display())))
}
/// 解析 `--kp 0.5..30` 形式的区间(刻度由预设 / 内置默认提供)。
///
/// # Errors
///
/// 区间文本非法时返回错误。
fn range(text: Option<&str>, name: &'static str) -> Result<Option<RangeSpec>> {
text.map(|text| {
// 先用线性刻度只解析区间;对数轴"下界必须为正"的检查留给最终解算,
// 这样只给区间、刻度继承预设时也能得到准确的报错字段。
let axis = parse_axis(name, text, tuning::Scale::Linear)?;
Ok(RangeSpec::new(axis.min, axis.max))
})
.transpose()
}
/// 用显式 `--kp-scale` 等覆盖合并结果的刻度(允许"只改刻度")。
fn apply_scales(preset: &mut Preset, cli: &CommonArgs) {
let set = |axis: &mut Option<RangeSpec>, scale: Option<ScaleArg>| {
if let (Some(axis), Some(scale)) = (axis.as_mut(), scale) {
axis.scale = Some(scale.into());
}
};
set(&mut preset.space.kp, cli.kp_scale);
set(&mut preset.space.ki, cli.ki_scale);
set(&mut preset.space.kd, cli.kd_scale);
set(&mut preset.space.feedforward, cli.feedforward_scale);
}
/// 用显式 `--jitter` 覆盖域随机化幅度。
///
/// # Errors
///
/// 抖动文本不是 `key=value` 或含未知键时返回错误。
fn apply_jitter(preset: &mut Preset, cli: &CommonArgs) -> Result<()> {
let Some(spec) = cli.sim.jitter.as_deref() else {
return Ok(());
};
for part in spec.split(',').map(str::trim).filter(|part| !part.is_empty()) {
let (key, value) =
part.split_once('=').with_context(|| format!("抖动参数 '{part}' 应为 key=value"))?;
let value: f64 =
value.trim().parse().with_context(|| format!("抖动参数 '{part}' 的数值无法解析"))?;
match key.trim() {
"gain" => preset.sim.gain_pct = Some(value as f32),
"tau" => preset.sim.tau_pct = Some(value as f32),
"delay" => preset.sim.delay_ticks_max = Some(value.max(0.0) as u32),
"friction" => preset.sim.friction_pct = Some(value as f32),
other => bail!("未知抖动参数 '{other}'(可用 gain/tau/delay/friction)"),
}
}
Ok(())
}
/// 后端选择:把两种评估器收进一个枚举,报告与后处理因此不必 `Box<dyn>`。
/// Test-only offline evaluator for search regression tests.
struct SimEvaluator {
ensemble: sim_harness::PlantEnsemble,
scenario: experiment::Scenario,
limits: sim_harness::LoopLimits,
objective: tuning::cost::Objective,
robust_weight: f64,
}
impl SimEvaluator {
/// Build an offline evaluator.
#[must_use]
pub fn new(
ensemble: sim_harness::PlantEnsemble,
scenario: experiment::Scenario,
limits: sim_harness::LoopLimits,
objective: tuning::cost::Objective,
robust_weight: f64,
) -> Self {
Self { ensemble, scenario, limits, objective, robust_weight }
}
}
impl Evaluator for SimEvaluator {
fn evaluate(&self, gains: &VelocityGains) -> Score {
let mut costs = Vec::with_capacity(self.ensemble.len());
let mut nominal = None;
let mut worst_verdict = tuning::cost::Verdict::Ok;
for (index, plant) in self.ensemble.iter().enumerate() {
let score = match sim_harness::simulate(*gains, plant, &self.scenario, self.limits) {
Ok(trace) => self.objective.score(&trace),
Err(error) => {
tuning::cost::Score::penalized(tuning::cost::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().max(1) as f64;
let mean = costs.iter().sum::<f64>() / count;
let worst = costs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let cost_value = (1.0 - self.robust_weight) * mean + self.robust_weight * worst;
let nominal = nominal
.unwrap_or_else(|| tuning::cost::Score::penalized(tuning::cost::Verdict::Rejected));
let verdict = if nominal.verdict.is_ok() { worst_verdict } else { nominal.verdict };
tuning::cost::Score { cost: cost_value, verdict, metrics: nominal.metrics }
}
fn describe(&self) -> String {
format!("sim(ensemble={}, robust_weight={:.2})", self.ensemble.len(), self.robust_weight)
}
}
/// Tuning adapter around the hardware-only bench runner.
struct TuningBench {
runner: BenchRunner,
objective: Objective,
continue_on_abort: bool,
best: Mutex<Option<(f64, experiment::Trace)>>,
aborts: Mutex<Vec<String>>,
describe: String,
}
impl TuningBench {
fn open(
link: can::CanLink,
config: BenchConfig,
objective: Objective,
continue_on_abort: bool,
) -> Result<Self> {
let describe = format!(
"bench(esc={}, name={}, limit={} raw, target={} rpm, trial={:.1}s, gap={}ms)",
config.esc.esc_id,
config.esc.name,
config.esc.current_limit,
config.scenario.target_rpm,
config.scenario.total_s(),
config.between_trials.as_millis()
);
let runner = BenchRunner::open(link, config).map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(Self {
runner,
objective,
continue_on_abort,
best: Mutex::new(None),
aborts: Mutex::new(Vec::new()),
describe,
})
}
fn best_trace(&self) -> Option<experiment::Trace> {
self.best
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.map(|(_, trace)| trace.clone())
}
fn aborts(&self) -> Vec<String> {
self.aborts.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
}
}
impl Evaluator for TuningBench {
fn evaluate(&self, gains: &VelocityGains) -> Score {
let outcome = self.runner.run_trial(gains);
let score = if let Some(reason) = outcome.abort {
self.aborts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(format!("{}: {reason}", self.describe));
Score::penalized(verdict_for(&reason))
} else {
match experiment::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));
}
if !score.verdict.is_ok() && !self.continue_on_abort {
tracing::warn!(verdict = score.verdict.label(), "bench trial did not pass");
}
score
}
fn describe(&self) -> String {
self.describe.clone()
}
}
impl TuningBench {
fn scenario(&self) -> experiment::Scenario {
self.runner.scenario()
}
}
/// Map a safety abort to a tuning verdict.
fn verdict_for(abort: &bench::Abort) -> Verdict {
match abort {
bench::Abort::Overspeed { .. } | bench::Abort::OverTemperature { .. } => Verdict::Overspeed,
bench::Abort::FeedbackLost { .. }
| bench::Abort::Timeout { .. }
| bench::Abort::Cancelled => Verdict::Rejected,
}
}
enum Backend {
Sim(Box<SimEvaluator>),
Bench(Box<TuningBench>),
}
impl Evaluator for Backend {
fn evaluate(&self, gains: &VelocityGains) -> Score {
match self {
Self::Sim(evaluator) => evaluator.evaluate(gains),
Self::Bench(evaluator) => evaluator.evaluate(gains),
}
}
fn describe(&self) -> String {
match self {
Self::Sim(evaluator) => evaluator.describe(),
Self::Bench(evaluator) => evaluator.describe(),
}
}
}
impl Backend {
fn bench(&self) -> Option<&TuningBench> {
match self {
Self::Bench(evaluator) => Some(evaluator),
Self::Sim(_) => None,
}
}
}
/// 场景的文本标签(报告 / `presets` 子命令用)。
fn profile_label(profile: SetpointProfile) -> &'static str {
match profile {
SetpointProfile::Step => "step",
SetpointProfile::RampAndHold => "ramp-and-hold",
SetpointProfile::Staircase { .. } => "staircase",
SetpointProfile::UpDown => "up-down",
}
}
/// 由解算后的权重与场景构造目标函数。
fn objective(settings: &Settings, limits: LoopLimits) -> Objective {
Objective::new(settings.tuning.weights, settings.tuning.scenario, limits)
}
/// 已知的机械 / 安全参数(CLI > `chassis.toml` > 内置默认)。
#[derive(Debug, Clone)]
struct Hardware {
/// 轮名。
name: String,
/// 电调 ID。
esc_id: u8,
/// 安装方向。
direction: f32,
/// 减速比。
gear_ratio: f32,
/// 限幅与型号。
limits: LoopLimits,
/// 基线增益(来自 `--config`;仅用于对比)。
baseline: Option<VelocityGains>,
}
/// 解析机械 / 安全参数:显式 CLI 覆盖 `--config`,`--config` 覆盖内置默认。
///
/// 这些参数**不参与搜索**(它们是安全与机械约束),但必须来自同一份配置——
/// 否则"整定工具用的限流"和"守护进程用的限流"会悄悄不一致。
fn hardware(settings: &Settings) -> Result<Hardware> {
let config = settings
.config
.as_ref()
.map(|path| Config::load(path).with_context(|| format!("读取配置 {}", path.display())))
.transpose()?;
let motor = match (&config, &settings.motor) {
(Some(config), name) => {
let index = config.motor_index(name).with_context(|| {
format!(
"配置里没有名为 '{name}' 的电机(可用:{})",
config.motors.iter().map(|m| m.name.as_str()).collect::<Vec<_>>().join(", ")
)
})?;
Some(&config.motors[index])
}
(None, _) => None,
};
let limits = LoopLimits {
current_limit_raw: settings
.current_limit
.or_else(|| motor.map(|m| m.current_limit))
.unwrap_or(12_000),
model: settings
.model
.map(Into::into)
.or_else(|| motor.map(|m| m.model.into()))
.unwrap_or(EscModel::C620),
};
if limits.current_limit_raw <= 0 {
bail!("电流限幅必须为正,得到 {}", limits.current_limit_raw);
}
Ok(Hardware {
name: settings.motor.clone(),
esc_id: settings.bench.esc_id.or_else(|| motor.map(|m| m.esc_id)).unwrap_or(1),
direction: settings.bench.direction.or_else(|| motor.map(|m| m.direction)).unwrap_or(1.0),
gear_ratio: settings
.bench
.gear_ratio
.or_else(|| motor.map(|m| m.gear_ratio))
.unwrap_or(19.203),
baseline: motor.map(chassis_config::MotorConfig::velocity_gains),
limits,
})
}
/// 线程池:真机后端强制单线程(只有一条总线),仿真后端默认吃满所有核。
fn install_thread_pool(threads: usize) -> Result<()> {
let mut builder = rayon::ThreadPoolBuilder::new();
if threads > 0 {
builder = builder.num_threads(threads);
}
builder.build_global().context("初始化线程池失败")?;
Ok(())
}
fn progress_bar(settings: &Settings) -> ProgressBar {
ProgressBar::new(settings.tuning.search.samples as u64 + settings.tuning.search.top_k as u64)
}
/// 构造后端(仿真或真机)。
fn build_backend(
settings: &Settings,
hardware: &Hardware,
limits: LoopLimits,
objective: Objective,
) -> Result<Backend> {
match settings.tuning.backend {
BackendSpec::Sim => {
let ensemble = PlantEnsemble::randomized(
settings.tuning.plant,
settings.tuning.tolerances,
settings.tuning.ensemble,
settings.tuning.search.seed,
);
Ok(Backend::Sim(Box::new(SimEvaluator::new(
ensemble,
settings.tuning.scenario,
limits,
objective,
settings.tuning.robust_weight,
))))
}
BackendSpec::Bench => {
Ok(Backend::Bench(Box::new(open_bench(settings, hardware, limits, objective)?)))
}
}
}
// ---------------------------------------------------------------- presets
/// 列出预设文件里的全部预设(只读,不搜索、不碰硬件)。
fn presets_cmd(args: &PresetsArgs) -> Result<ExitCode> {
let file = PresetFile::load(&args.preset)
.with_context(|| format!("加载预设文件 {}", args.preset.display()))?;
println!("=== 预设文件 {}(version {})===", args.preset.display(), file.version);
println!("共 {} 个预设", file.preset.len());
for (name, preset) in &file.preset {
let merged = Preset::builtin().overlay(preset);
let tuning = merged.to_tuning().with_context(|| format!("解算预设 '{name}' 失败"))?;
let envelope =
merged.bench_envelope().with_context(|| format!("解算预设 '{name}' 的安全包络失败"))?;
let mark =
if file.default_preset.as_deref() == Some(name.as_str()) { "(默认)" } else { "" };
println!("\n{name}{mark}:{}", preset.description.as_deref().unwrap_or("(无说明)"));
println!(
" 后端 {}|场景 {}|目标 {:.0} rpm|dt {:.0}µs|样本 {}|top-k {}|种子 {}|求解器 {}",
tuning.backend.name(),
profile_label(tuning.scenario.profile),
tuning.scenario.target_rpm,
tuning.scenario.dt_s * 1e6,
tuning.search.samples,
tuning.search.top_k,
tuning.search.seed,
tuning.search.solver.name(),
);
println!(" 空间:{}", tuning.space.describe());
println!(
" 权重:{}|验收 超调 ≤ {:.1}% / 稳态误差 ≤ {:.1}%",
tuning.weights.describe(),
tuning.acceptance.max_overshoot_pct,
tuning.acceptance.max_ss_error_pct
);
println!(
" 被控对象 {}|集成 {}|鲁棒权重 {:.2}|抖动 gain±{:.0}% tau±{:.0}% delay≤{}拍 friction±{:.0}%",
tuning.plant_spec.name(),
tuning.ensemble,
tuning.robust_weight,
tuning.tolerances.gain_pct * 100.0,
tuning.tolerances.tau_pct * 100.0,
tuning.tolerances.delay_ticks_max,
tuning.tolerances.friction_pct * 100.0,
);
println!(
" 安全包络:过速 {:.0} rpm|温度 ≤ {}℃|试验超时 {}ms|启动等待 {}ms|试验间隔 {}ms|复验 {} 次|安全事件后{}",
envelope.max_rpm,
envelope.max_temp_c,
envelope.trial_timeout_ms,
envelope.startup_timeout_ms,
envelope.between_trials_ms,
envelope.validate_runs,
if envelope.continue_on_abort { "继续" } else { "收工" },
);
}
println!("\n用法:chassis-tune sweep --preset {} --preset-name <NAME>", args.preset.display());
Ok(ExitCode::SUCCESS)
}
// ---------------------------------------------------------------- sweep
fn sweep_cmd(settings: &Settings) -> Result<ExitCode> {
let hardware = hardware(settings)?;
let limits = hardware.limits;
let objective = objective(settings, limits);
let config = settings.tuning.search.clone();
install_thread_pool(match settings.tuning.backend {
BackendSpec::Bench => 1,
BackendSpec::Sim => settings.tuning.threads,
})?;
println!("=== chassis-tune sweep ===");
println!("超参数来源:{}", settings.source);
println!("参数空间:{}", settings.tuning.space.describe());
println!("场景:{}", tuning::search::scenario_summary(&settings.tuning.scenario));
println!(
"轮子:{}|电调 {}|方向 {:+.0}|减速比 {:.3}|限流 {} raw",
hardware.name,
hardware.esc_id,
hardware.direction,
hardware.gear_ratio,
limits.current_limit_raw
);
let evaluator = build_backend(settings, &hardware, limits, objective)?;
println!("后端:{}", evaluator.describe());
println!(
"种子:{}|粗扫 {}|top-k {}|求解器 {}",
config.seed,
config.samples,
config.top_k,
config.solver.name()
);
if settings.tuning.backend == BackendSpec::Bench {
let trials = config.samples as u64
+ config.top_k as u64 * 2
+ settings.envelope.validate_runs as u64;
let per_trial = settings.tuning.scenario.total_s()
+ settings.envelope.between_trials_ms as f32 / 1000.0;
println!(
"⚠ 真机模式:轮子会转!预计 {} 次试验 × {:.1}s ≈ {:.0}s",
trials,
per_trial,
trials as f32 * per_trial
);
}
let bar = progress_bar(settings);
let ranked = sweep(&settings.tuning.space, &config, &evaluator, Some(&bar))?;
bar.finish_and_clear();
// 最优轨迹:仿真后端重放名义模型;真机后端用搜索期间记录的最好一次。
let trace = match &evaluator {
Backend::Sim(_) => sim_harness::simulate(
ranked.best.gains,
&settings.tuning.plant,
&settings.tuning.scenario,
limits,
)
.ok(),
Backend::Bench(evaluator) => evaluator.best_trace(),
};
let ctx = ReportContext {
out_dir: settings.out_dir.clone(),
space: settings.tuning.space,
config: config.clone(),
scenario: settings.tuning.scenario,
limits,
backend: evaluator.describe(),
motor_name: settings.motor.clone(),
max_overshoot_pct: settings.tuning.acceptance.max_overshoot_pct,
max_ss_error_pct: settings.tuning.acceptance.max_ss_error_pct,
best_trace: trace,
};
let written = write_reports(&ctx, &ranked)?;
print_ranked(&ranked);
if settings.json {
println!("\n{}", serde_json::to_string_pretty(&summary_json(&ctx, &ranked))?);
}
println!("\n产物:");
for path in &written {
println!(" {}", path.display());
}
compare_with_baseline(&evaluator, &hardware, &ranked);
// 真机:对最优增益做重复性复验(仿真到真机是否可迁移的证据)。
if let Some(bench_evaluator) = evaluator.bench() {
hil_validate(
bench_evaluator,
ranked.best.gains,
settings.envelope.validate_runs,
&settings.out_dir,
)?;
print_aborts(bench_evaluator);
}
if let Some(path) = &settings.write_config {
write_config(settings, &hardware, ranked.best.gains, path)?;
}
let passes = ranked.best.score.metrics.is_some_and(|metrics| ctx.best_passes(&metrics));
Ok(exit_code(passes))
}
/// 与 `--config` 里的现有增益对比(有基线才有意义)。
fn compare_with_baseline(evaluator: &Backend, hardware: &Hardware, ranked: &Ranked) {
let Some(baseline) = hardware.baseline else {
return;
};
let base = evaluator.evaluate(&baseline);
println!(
"\n基线(来自 --config):kp={:.3} ki={:.3} kd={:.3} ff={:.3}|成本 {:.4}|判定 {}",
baseline.kp,
baseline.ki,
baseline.kd,
baseline.feedforward,
base.cost,
base.verdict.label()
);
if base.cost > 0.0 {
println!(
" → 整定后成本 {:.4}(基线的 {:.0}%)",
ranked.best.cost(),
ranked.best.cost() / base.cost * 100.0
);
}
}
/// 打印真机搜索期间触发的安全事件(最多 5 条)。
fn print_aborts(evaluator: &TuningBench) {
let aborts = evaluator.aborts();
if aborts.is_empty() {
return;
}
println!("\n⚠ 搜索期间触发 {} 次安全事件:", aborts.len());
for abort in aborts.iter().take(5) {
println!(" - {abort}");
}
}
/// 把整定结果写回一份 `chassis.toml`(只改写目标电机的四个增益)。
fn write_config(
settings: &Settings,
hardware: &Hardware,
gains: VelocityGains,
path: &Path,
) -> Result<()> {
let source = settings
.config
.as_ref()
.context("--write-config 必须与 --config 一起使用(否则不知道该从哪份配置派生)")?;
let mut config =
Config::load(source).with_context(|| format!("读取配置 {}", source.display()))?;
config
.set_motor_gains_by_name(&hardware.name, gains)
.with_context(|| format!("配置里没有名为 '{}' 的电机", hardware.name))?;
let text = config.to_toml_string().context("序列化配置失败")?;
std::fs::write(path, text).with_context(|| format!("写入 {}", path.display()))?;
println!(
"\n已写出 {}(仅 {} 的 kp/ki/kd/feedforward 改写为 {:.3}/{:.3}/{:.3}/{:.3})",
path.display(),
hardware.name,
gains.kp,
gains.ki,
gains.kd,
gains.feedforward
);
Ok(())
}
/// `hil-validation.csv` 的一行。
#[derive(Debug, Serialize)]
struct HilRow {
run: usize,
cost: f64,
verdict: &'static str,
settled: bool,
overshoot_pct: f32,
ss_error_pct: f32,
settling_time_s: f32,
peak_effort_raw: f32,
}
/// 真机重复性复验:同一组增益跑 `runs` 次,写出 `hil-validation.csv`。
fn hil_validate(
evaluator: &TuningBench,
gains: VelocityGains,
runs: usize,
out_dir: &Path,
) -> Result<()> {
let runs = runs.max(1);
println!("\n=== 真机复验({runs} 次)===");
let mut rows = Vec::with_capacity(runs);
for run in 0..runs {
let score = evaluator.evaluate(&gains);
let metrics = score.metrics;
let row = HilRow {
run: run + 1,
cost: score.cost,
verdict: score.verdict.label(),
settled: metrics.is_some_and(|m| m.settled),
overshoot_pct: metrics.map_or(f32::NAN, |m| m.overshoot_pct),
ss_error_pct: metrics.map_or(f32::NAN, |m| m.ss_error_pct),
settling_time_s: metrics.map_or(f32::NAN, |m| m.settling_time_s),
peak_effort_raw: metrics.map_or(f32::NAN, |m| m.peak_effort_raw),
};
println!(
" 第 {} 次:{}|超调 {:.1}%|稳态误差 {:.2}%|调节 {:.0}ms",
row.run,
row.verdict,
row.overshoot_pct,
row.ss_error_pct,
row.settling_time_s * 1000.0
);
rows.push(row);
}
let ok = rows.iter().filter(|row| row.verdict == "ok").count();
println!("复验结果:{ok} / {runs} 次达标");
let path = out_dir.join("hil-validation.csv");
let mut writer =
csv::Writer::from_path(&path).with_context(|| format!("写出 {}", path.display()))?;
for row in &rows {
writer.serialize(row)?;
}
writer.flush()?;
println!(" → {}", path.display());
Ok(())
}
fn print_ranked(ranked: &Ranked) {
println!("\n=== Top 5 ===");
println!(
"{:<4} {:<8} {:>9} {:>9} {:>9} {:>9} {:>10} {:<11}",
"#", "阶段", "kp", "ki", "kd", "ff", "成本", "判定"
);
for (rank, trial) in ranked.top(5).iter().enumerate() {
println!(
"{:<4} {:<8} {:>9.3} {:>9.3} {:>9.3} {:>9.3} {:>10.4} {:<11}",
rank + 1,
trial.phase.label(),
trial.gains.kp,
trial.gains.ki,
trial.gains.kd,
trial.gains.feedforward,
trial.cost(),
trial.score.verdict.label()
);
}
let best = &ranked.best;
println!(
"\n最优:kp={:.3} ki={:.3} kd={:.3} ff={:.3}(成本 {:.4},判定 {})",
best.gains.kp,
best.gains.ki,
best.gains.kd,
best.gains.feedforward,
best.cost(),
best.score.verdict.label()
);
if let Some(metrics) = best.score.metrics {
println!(
"指标:上升 {:.0}ms|超调 {:.1}%|调节 {:.0}ms|稳态误差 {:.2}%|峰值电流 {:.0} raw|饱和 {:.1}%",
metrics.rise_time_s * 1000.0,
metrics.overshoot_pct,
metrics.settling_time_s * 1000.0,
metrics.ss_error_pct,
metrics.peak_effort_raw,
metrics.saturated_pct
);
}
println!(
"评估 {} 次:达标 {} / {}({} 次被硬约束拦下)",
ranked.evals,
ranked.ok_count(),
ranked.trials.len(),
ranked.rejected
);
}
fn summary_json(ctx: &ReportContext, ranked: &Ranked) -> serde_json::Value {
serde_json::json!({
"backend": ctx.backend,
"seed": ctx.config.seed,
"samples": ctx.config.samples,
"evals": ranked.evals,
"ok": ranked.ok_count(),
"rejected": ranked.rejected,
"best": {
"kp": ranked.best.gains.kp,
"ki": ranked.best.gains.ki,
"kd": ranked.best.gains.kd,
"feedforward": ranked.best.gains.feedforward,
"cost": ranked.best.cost(),
"verdict": ranked.best.score.verdict.label(),
},
})
}
fn exit_code(passes: bool) -> ExitCode {
if passes { ExitCode::SUCCESS } else { ExitCode::from(1) }
}
// ---------------------------------------------------------------- validate
fn validate_cmd(settings: &Settings, gains_text: &str) -> Result<ExitCode> {
let hardware = hardware(settings)?;
let gains = parse_gains(gains_text)?;
let limits = hardware.limits;
let objective = objective(settings, limits);
// 单组增益不需要并行:单线程既省事又让真机路径的顺序确定。
install_thread_pool(1)?;
let evaluator = build_backend(settings, &hardware, limits, objective.clone())?;
println!("=== chassis-tune validate ===");
println!("超参数来源:{}", settings.source);
println!("后端:{}", evaluator.describe());
println!("增益:kp={} ki={} kd={} ff={}", gains.kp, gains.ki, gains.kd, gains.feedforward);
let score = evaluator.evaluate(&gains);
let acceptance = settings.tuning.acceptance;
let passes = score.metrics.is_some_and(|metrics| {
objective.passes(&metrics, acceptance.max_overshoot_pct, acceptance.max_ss_error_pct)
});
println!("判定:{}(成本 {:.6})", score.verdict.label(), score.cost);
match score.metrics {
Some(metrics) => {
println!(
"上升 {:.0}ms|超调 {:.1}%|调节 {:.0}ms|稳态误差 {:.2}%|峰值电流 {:.0} raw|饱和 {:.1}%",
metrics.rise_time_s * 1000.0,
metrics.overshoot_pct,
metrics.settling_time_s * 1000.0,
metrics.ss_error_pct,
metrics.peak_effort_raw,
metrics.saturated_pct
);
println!(
"验收(超调 ≤ {:.1}%,稳态误差 ≤ {:.1}%):{}",
acceptance.max_overshoot_pct,
acceptance.max_ss_error_pct,
if passes { "✓ 通过" } else { "✗ 未通过" }
);
}
None => println!("没有可用指标({})", tuning::search::verdict_hint(score.verdict)),
}
Ok(exit_code(passes))
}
/// `kp,ki,kd,ff` → 增益组。
fn parse_gains(text: &str) -> Result<VelocityGains> {
let parts: Vec<&str> = text.split(',').map(str::trim).collect();
if parts.len() != 4 {
bail!("--gains 需要四个逗号分隔的数值(kp,ki,kd,feedforward),得到 '{text}'");
}
let value = |index: usize| -> Result<f32> {
parts[index]
.parse::<f32>()
.with_context(|| format!("--gains 第 {} 项 '{}' 不是数值", index + 1, parts[index]))
};
let gains = VelocityGains::new(value(0)?, value(1)?, value(2)?, value(3)?);
if !gains.is_finite() {
bail!("--gains 必须是有限值");
}
Ok(gains)
}
// ---------------------------------------------------------------- bench
/// 打开真机后端(含安全横幅与确认门控)。
fn open_bench(
settings: &Settings,
hardware: &Hardware,
limits: LoopLimits,
objective: Objective,
) -> Result<TuningBench> {
let bench = &settings.bench;
let envelope_spec = settings.envelope;
if !bench.i_know_the_wheel_spins {
bail!(
"真机模式必须显式确认:加 `--i-know-the-wheel-spins`。\n \
前提:轮子架空或整车固定、急停可及、电流限流已按台架设置(当前 {} raw)。",
limits.current_limit_raw
);
}
println!("⚠ 台架模式:轮子会以最高 {:.0} rpm(转子)转动。", envelope_spec.max_rpm);
println!(" 确认:轮子已架空或整车固定,急停可及,ctrl-c 随时安全停机。");
let link = open_link(
&bench.device,
bench.serial_baud,
bench.can_speed.into(),
bench.interface.as_deref(),
)?;
let mut esc = EscConfig::gm3508(hardware.name.clone(), hardware.esc_id, hardware.direction);
esc.gear_ratio = hardware.gear_ratio;
esc.current_limit = limits.current_limit_raw;
esc.model = limits.model;
let envelope = SafetyEnvelope::new()
.with_overspeed(envelope_spec.max_rpm, Duration::from_millis(60))
.with_temperature_max(envelope_spec.max_temp_c)
.with_feedback_timeout(Duration::from_millis(200))
.with_trial_timeout(Duration::from_millis(envelope_spec.trial_timeout_ms));
let mut config = BenchConfig::new(esc, settings.tuning.scenario, envelope);
config.between_trials = Duration::from_millis(envelope_spec.between_trials_ms);
config.startup_timeout = Duration::from_millis(envelope_spec.startup_timeout_ms);
TuningBench::open(link, config, objective, envelope_spec.continue_on_abort)
.map_err(|e| anyhow::anyhow!("{e}"))
}
#[cfg(test)]
mod tests {
use super::*;
/// 一份最小可用配置(四轮,顺序 FL/FR/RL/RR)。
const CONFIG: &str = r#"
[can]
interface = "can0"
[mecanum]
half_length = 0.2
half_width = 0.25
wheel_radius = 0.0762
[[motor]]
name = "front_left"
esc_id = 3
direction = -1
gear_ratio = 19.203
current_limit = 6000
kp = 2.0
ki = 10.0
kd = 0.0
feedforward = 0.0
[[motor]]
name = "front_right"
esc_id = 4
direction = 1
gear_ratio = 19.203
current_limit = 6000
kp = 2.0
ki = 10.0
kd = 0.0
feedforward = 0.0
[[motor]]
name = "rear_left"
esc_id = 5
direction = -1
gear_ratio = 19.203
current_limit = 6000
kp = 2.0
ki = 10.0
kd = 0.0
feedforward = 0.0
[[motor]]
name = "rear_right"
esc_id = 6
direction = 1
gear_ratio = 19.203
current_limit = 6000
kp = 2.0
ki = 10.0
kd = 0.0
feedforward = 0.0
"#;
fn write_temp(name: &str, text: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!("chassis-tune-cli-{name}.toml"));
std::fs::write(&path, text).unwrap();
path
}
/// 仓库里随代码一起评审的预设文件。
fn repo_preset_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../config/tune.toml")
}
fn args_with(extra: &[&str]) -> CommonArgs {
let mut argv = vec!["chassis-tune", "sweep"];
argv.extend_from_slice(extra);
match Cli::parse_from(argv).command {
Command::Sweep(sweep) => sweep.common,
_ => panic!("expected sweep"),
}
}
/// 解算后的设置(内置默认 + 额外 CLI)。
fn settings_with(extra: &[&str]) -> Settings {
args_with(extra).resolve().unwrap()
}
#[test]
fn hardware_defaults_without_config() {
let settings = settings_with(&[]);
let hardware = hardware(&settings).unwrap();
assert_eq!(hardware.esc_id, 1);
assert!((hardware.direction - 1.0).abs() < 1e-6);
assert!((hardware.gear_ratio - 19.203).abs() < 1e-6);
assert_eq!(hardware.limits.current_limit_raw, 12_000);
assert!(hardware.baseline.is_none());
}
#[test]
fn hardware_takes_mechanical_parameters_from_config() {
let path = write_temp("mech", CONFIG);
let settings =
settings_with(&["--config", path.to_str().unwrap(), "--motor", "front_left"]);
let hardware = hardware(&settings).unwrap();
assert_eq!(hardware.esc_id, 3, "电调 ID 必须来自配置");
assert_eq!(hardware.direction.to_bits(), (-1.0f32).to_bits());
assert_eq!(hardware.limits.current_limit_raw, 6000);
assert_eq!(hardware.baseline.unwrap(), VelocityGains::new(2.0, 10.0, 0.0, 0.0));
let _ = std::fs::remove_file(&path);
}
#[test]
fn explicit_cli_overrides_the_config() {
let path = write_temp("override", CONFIG);
let settings = settings_with(&[
"--config",
path.to_str().unwrap(),
"--motor",
"front_left",
"--current-limit",
"1500",
]);
let hardware = hardware(&settings).unwrap();
assert_eq!(hardware.limits.current_limit_raw, 1500, "显式 CLI 必须覆盖配置");
assert_eq!(hardware.esc_id, 3, "未显式给出的字段仍取配置");
let _ = std::fs::remove_file(&path);
}
#[test]
fn unknown_motor_name_is_rejected() {
let path = write_temp("unknown", CONFIG);
let settings = settings_with(&["--config", path.to_str().unwrap(), "--motor", "ghost"]);
let error = hardware(&settings).unwrap_err().to_string();
assert!(error.contains("ghost"), "{error}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn write_config_only_touches_the_target_motor() {
let source = write_temp("write-src", CONFIG);
let out = std::env::temp_dir().join("chassis-tune-cli-write-out.toml");
let settings = settings_with(&[
"--config",
source.to_str().unwrap(),
"--motor",
"front_left",
"--write-config",
out.to_str().unwrap(),
]);
let hardware = hardware(&settings).unwrap();
let gains = VelocityGains::new(4.5, 22.0, 0.25, 0.75);
write_config(&settings, &hardware, gains, &out).unwrap();
let written = Config::load(&out).unwrap();
assert_eq!(written.motors[0].velocity_gains(), gains);
assert_eq!(
written.motors[1].velocity_gains(),
VelocityGains::new(2.0, 10.0, 0.0, 0.0),
"其它电机不得被改写"
);
assert_eq!(written.motors[0].current_limit, 6000, "安全字段不得被改写");
assert_eq!(written.motors[0].direction.to_bits(), (-1.0f32).to_bits());
let _ = std::fs::remove_file(&source);
let _ = std::fs::remove_file(&out);
}
#[test]
fn write_config_requires_config() {
let settings = settings_with(&[]);
let hardware = hardware(&settings).unwrap();
let out = std::env::temp_dir().join("chassis-tune-cli-never.toml");
let error =
write_config(&settings, &hardware, VelocityGains::ZERO, &out).unwrap_err().to_string();
assert!(error.contains("--config"), "{error}");
assert!(!out.exists(), "报错时不得写出文件");
}
// ------------------------------------------------ 超参数优先级
#[test]
fn defaults_match_the_documented_builtin_values() {
let settings = settings_with(&[]);
assert_eq!(settings.source, "内置默认");
let tuning = &settings.tuning;
assert_eq!(tuning.backend, BackendSpec::Sim);
assert!((tuning.space.kp.min - 0.5).abs() < 1e-12);
assert!((tuning.space.kp.max - 30.0).abs() < 1e-12);
assert_eq!(tuning.search.samples, 512);
assert_eq!(tuning.search.seed, 20_260_101);
assert!((tuning.scenario.target_rpm - 1500.0).abs() < 1e-6);
assert!((tuning.acceptance.max_overshoot_pct - 20.0).abs() < 1e-6);
assert_eq!(tuning.plant_spec, PlantSpec::Gm3508Bench);
assert_eq!(settings.envelope.validate_runs, 3);
}
#[test]
fn preset_supplies_hyperparameters_and_cli_overrides_them() {
let path = write_temp(
"preset",
r#"
version = 1
default_preset = "quick"
[preset.quick]
description = "快跑"
[preset.quick.space]
kp = { min = 1.0, max = 4.0, scale = "log" }
[preset.quick.search]
samples = 24
top_k = 3
seed = 7
[preset.other]
[preset.other.search]
samples = 99
"#,
);
// 预设生效,来源可追溯。
let settings = settings_with(&["--preset", path.to_str().unwrap()]);
assert!(settings.source.ends_with("#quick"), "{}", settings.source);
assert_eq!(settings.tuning.search.samples, 24);
assert_eq!(settings.tuning.search.top_k, 3);
assert_eq!(settings.tuning.search.seed, 7);
assert!((settings.tuning.space.kp.max - 4.0).abs() < 1e-12);
// 预设没写的项继续沿用内置默认。
assert_eq!(settings.tuning.search.max_iters, 200);
assert!((settings.tuning.scenario.target_rpm - 1500.0).abs() < 1e-6);
// 显式 CLI 覆盖预设的同一项,但不影响预设的其它项。
let settings = settings_with(&["--preset", path.to_str().unwrap(), "--samples", "128"]);
assert_eq!(settings.tuning.search.samples, 128, "CLI 必须覆盖预设");
assert_eq!(settings.tuning.search.top_k, 3, "预设的其它项必须保留");
assert_eq!(settings.tuning.search.seed, 7);
// 按名字选另一个预设。
let settings =
settings_with(&["--preset", path.to_str().unwrap(), "--preset-name", "other"]);
assert!(settings.source.ends_with("#other"), "{}", settings.source);
assert_eq!(settings.tuning.search.samples, 99);
let _ = std::fs::remove_file(&path);
}
#[test]
fn unknown_preset_name_lists_the_available_ones() {
let path = write_temp("unknown-preset", "version = 1\n[preset.alpha]\n[preset.beta]\n");
let error = args_with(&["--preset", path.to_str().unwrap(), "--preset-name", "ghost"])
.resolve()
.unwrap_err()
.to_string();
assert!(error.contains("ghost"), "{error}");
assert!(error.contains("alpha") && error.contains("beta"), "{error}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn preset_name_without_file_falls_back_to_the_default_path() {
// 用临时文件充作"默认路径",验证"只给 --preset-name"的回落逻辑
// (不依赖当前工作目录,因此可测)。
let path = write_temp(
"fallback",
"version = 1\ndefault_preset = \"only\"\n[preset.only]\n[preset.only.search]\nsamples = 11\n",
);
let (file, prefix) = preset_source(None, Some("only"), &path).unwrap();
assert!(prefix.ends_with('#'), "{prefix}");
assert_eq!(file.select(Some("only")).unwrap().search.samples, Some(11));
// 默认路径不存在时必须给出可操作的报错。
let error = preset_source(None, Some("only"), Path::new("/definitely/not/here/tune.toml"))
.unwrap_err()
.to_string();
assert!(error.contains("--preset"), "{error}");
// 没给名字也没给文件 → 内置库。
let (file, prefix) = preset_source(None, None, &path).unwrap();
assert!(prefix.is_empty());
assert!(file.preset.contains_key("default"));
// 端到端:仓库里的 config/tune.toml 能按名字直接选中(避开 cwd 依赖)。
let settings = args_with(&[
"--preset",
repo_preset_path().to_str().unwrap(),
"--preset-name",
"bench_noload",
])
.resolve()
.unwrap();
assert!(settings.source.ends_with("#bench_noload"), "{}", settings.source);
assert_eq!(settings.tuning.backend, BackendSpec::Bench);
let _ = std::fs::remove_file(&path);
}
#[test]
fn presets_cannot_smuggle_safety_parameters() {
// current_limit / direction / gear_ratio 不是预设的字段:写进去必须报错,
// 否则预设文件就成了绕过限流的口子。
for key in ["current_limit = 16384", "direction = -1.0", "gear_ratio = 1.0"] {
let text = format!("version = 1\n[preset.a.bench]\n{key}\n");
assert!(PresetFile::parse_str(&text).is_err(), "'{key}' 不得被预设接受");
}
}
#[test]
fn scale_can_be_overridden_without_touching_the_range() {
// 只给 --kp-scale:区间必须仍然来自内置默认。
let settings = settings_with(&["--kp-scale", "linear"]);
assert_eq!(settings.tuning.space.kp.scale, tuning::Scale::Linear);
assert!((settings.tuning.space.kp.min - 0.5).abs() < 1e-12);
assert!((settings.tuning.space.kp.max - 30.0).abs() < 1e-12);
// 只给区间:刻度必须从内置默认继承(Log)。
let settings = settings_with(&["--kp", "1..10"]);
assert!((settings.tuning.space.kp.min - 1.0).abs() < 1e-12);
assert!((settings.tuning.space.kp.max - 10.0).abs() < 1e-12);
assert_eq!(settings.tuning.space.kp.scale, tuning::Scale::Log);
}
#[test]
fn continue_on_abort_only_ever_turns_on() {
assert!(!settings_with(&[]).envelope.continue_on_abort);
assert!(settings_with(&["--continue-on-abort"]).envelope.continue_on_abort);
}
#[test]
fn the_repo_preset_file_resolves_completely() {
let path = repo_preset_path();
let file = PresetFile::load(&path).unwrap_or_else(|error| {
panic!("{} 必须能被加载:{error}", path.display());
});
assert!(!file.preset.is_empty());
let default = file.default_preset.as_deref().expect("必须指定 default_preset");
assert!(file.preset.contains_key(default), "default_preset '{default}' 必须存在");
for (name, preset) in &file.preset {
let merged = Preset::builtin().overlay(preset);
let tuning = merged
.to_tuning()
.unwrap_or_else(|error| panic!("预设 '{name}' 无法解算:{error}"));
merged
.bench_envelope()
.unwrap_or_else(|error| panic!("预设 '{name}' 的安全包络非法:{error}"));
assert!(
preset.description.as_deref().is_some_and(|text| !text.is_empty()),
"预设 '{name}' 必须有说明(评审时要知道它是给什么工况用的)"
);
// 命名约定必须与后端一致:否则一个叫 bench_* 的预设可能悄悄跑离线,
// 拿它当"台架已验证"的证据就是错的。
let expected = if name.starts_with("bench_") || name.starts_with("field_") {
BackendSpec::Bench
} else {
BackendSpec::Sim
};
assert_eq!(
tuning.backend,
expected,
"预设 '{name}' 的后端 ({}) 与命名约定不符",
tuning.backend.name()
);
}
}
#[test]
fn presets_cmd_lists_every_preset() {
let args = PresetsArgs { preset: repo_preset_path() };
assert_eq!(presets_cmd(&args).unwrap(), ExitCode::SUCCESS);
}
// ------------------------------------------------ 参数校验
#[test]
fn gain_space_comes_from_cli_ranges() {
let settings = settings_with(&["--kp", "1..10", "--ki", "0..5", "--ki-scale", "linear"]);
let space = settings.tuning.space;
assert!((space.kp.min - 1.0).abs() < 1e-12);
assert!((space.kp.max - 10.0).abs() < 1e-12);
assert_eq!(space.ki.scale, tuning::Scale::Linear);
}
#[test]
fn bad_ranges_are_rejected() {
assert!(args_with(&["--kp", "abc"]).resolve().is_err());
assert!(args_with(&["--kp=-1..5"]).resolve().is_err(), "对数轴的负下界必须被拒绝");
}
#[test]
fn scenario_comes_from_cli() {
let settings = settings_with(&[
"--dt-us",
"2000",
"--ramp-ms",
"100",
"--hold-ms",
"900",
"--target-rpm",
"800",
]);
let scenario = settings.tuning.scenario;
assert!((scenario.dt_s - 0.002).abs() < 1e-9);
assert!((scenario.ramp_s - 0.1).abs() < 1e-6);
assert!((scenario.total_s() - 1.0).abs() < 1e-6);
assert!((scenario.target_rpm - 800.0).abs() < 1e-6);
}
#[test]
fn degenerate_scenario_is_rejected() {
assert!(args_with(&["--dt-us", "0"]).resolve().is_err());
assert!(
args_with(&["--hold-ms", "0", "--ramp-ms", "0", "--scenario", "step"])
.resolve()
.is_err()
);
}
#[test]
fn gains_parse_requires_four_finite_values() {
assert_eq!(parse_gains("2, 10, 0, 0.5").unwrap(), VelocityGains::new(2.0, 10.0, 0.0, 0.5));
assert!(parse_gains("2,10,0").is_err());
assert!(parse_gains("2,10,0,inf").is_err());
}
#[test]
fn jitter_parses_known_keys_only() {
let settings = settings_with(&["--jitter", "gain=0.1,tau=0.2,delay=2,friction=0.3"]);
let tolerances = settings.tuning.tolerances;
assert!((tolerances.gain_pct - 0.1).abs() < 1e-6);
assert!((tolerances.tau_pct - 0.2).abs() < 1e-6);
assert_eq!(tolerances.delay_ticks_max, 2);
assert!((tolerances.friction_pct - 0.3).abs() < 1e-6);
assert!(args_with(&["--jitter", "gain"]).resolve().is_err());
assert!(args_with(&["--jitter", "gain=abc"]).resolve().is_err());
assert!(args_with(&["--jitter", "unknown=1"]).resolve().is_err());
}
#[test]
fn negative_weights_are_rejected() {
// 负数必须用 `=` 形式,否则 clap 会把它当成选项。
assert!(args_with(&["--w-iae=-1"]).resolve().is_err());
assert!(args_with(&["--max-overshoot=-1"]).resolve().is_err());
}
#[test]
fn cli_requires_a_subcommand() {
assert!(Cli::try_parse_from(["chassis-tune"]).is_err());
}
}