2826 lines
100 KiB
Rust
2826 lines
100 KiB
Rust
#![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());
|
||
}
|
||
}
|