feat: 添加了自动超参数整定
This commit is contained in:
@@ -0,0 +1,713 @@
|
||||
//! 报告与产物写出:CSV / TOML / JSON / Markdown(可选 SVG)。
|
||||
//!
|
||||
//! 产物布局(`--out-dir`):
|
||||
//!
|
||||
//! | 文件 | 内容 |
|
||||
//! | --- | --- |
|
||||
//! | `trials.csv` | 全部候选:增益 + 指标 + 成本 + 判定(可用任意表格软件复盘) |
|
||||
//! | `best.toml` | 可直接粘贴进 `chassis.toml` 的 `[[motor]]` 片段 |
|
||||
//! | `best-trace.csv` | 最优增益的完整步响应轨迹 |
|
||||
//! | `report.md` | 人类可读总结:设置、Top-N、结论与注意事项 |
|
||||
//! | `summary.json` | 机器可读结果(CI / 后续工具消费) |
|
||||
//! | `best.svg` | 步响应三联图(仅 `--features plot`) |
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dji_esc_sim::{LoopLimits, Scenario, Trace};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::gains::GainSpace;
|
||||
use crate::metrics::StepMetrics;
|
||||
use crate::search::{Ranked, SearchConfig, Trial, verdict_hint};
|
||||
|
||||
/// 报告写出错误。
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ReportError {
|
||||
/// 目录创建或文件写入失败。
|
||||
#[error("io error at {path}: {source}")]
|
||||
Io {
|
||||
/// 出错路径。
|
||||
path: PathBuf,
|
||||
/// 底层错误。
|
||||
source: std::io::Error,
|
||||
},
|
||||
/// CSV 写出失败。
|
||||
#[error("csv error: {0}")]
|
||||
Csv(#[from] csv::Error),
|
||||
/// JSON 写出失败。
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
/// TOML 写出失败。
|
||||
#[error("toml error: {0}")]
|
||||
Toml(#[from] toml::ser::Error),
|
||||
/// 没有可用的最优结果。
|
||||
#[error("no feasible trial to report")]
|
||||
NoBest,
|
||||
/// SVG 绘图失败(仅 `--features plot`)。
|
||||
#[error("plot error: {0}")]
|
||||
Plot(String),
|
||||
}
|
||||
|
||||
fn io(path: impl Into<PathBuf>, source: std::io::Error) -> ReportError {
|
||||
ReportError::Io { path: path.into(), source }
|
||||
}
|
||||
|
||||
/// 生成产物所需的上下文。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportContext {
|
||||
/// 输出目录。
|
||||
pub out_dir: PathBuf,
|
||||
/// 本次搜索使用的参数空间。
|
||||
pub space: GainSpace,
|
||||
/// 搜索配置。
|
||||
pub config: SearchConfig,
|
||||
/// 场景。
|
||||
pub scenario: Scenario,
|
||||
/// 限幅。
|
||||
pub limits: LoopLimits,
|
||||
/// 后端描述(`sim(...)` / `bench(...)`)。
|
||||
pub backend: String,
|
||||
/// 目标轮名(写回 TOML 用)。
|
||||
pub motor_name: String,
|
||||
/// 验收阈值:最大超调(%)。
|
||||
pub max_overshoot_pct: f32,
|
||||
/// 验收阈值:最大稳态误差(%)。
|
||||
pub max_ss_error_pct: f32,
|
||||
/// 最优增益的轨迹(有则写 `best-trace.csv`)。
|
||||
pub best_trace: Option<Trace>,
|
||||
}
|
||||
|
||||
/// 写出全部产物,返回写出的文件列表。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 目录创建、文件写入或序列化失败时返回 [`ReportError`]。
|
||||
pub fn write_reports(ctx: &ReportContext, ranked: &Ranked) -> Result<Vec<PathBuf>, ReportError> {
|
||||
std::fs::create_dir_all(&ctx.out_dir).map_err(|e| io(&ctx.out_dir, e))?;
|
||||
let mut written = Vec::new();
|
||||
|
||||
let trials_path = ctx.out_dir.join("trials.csv");
|
||||
write_trials_csv(&trials_path, &ranked.trials)?;
|
||||
written.push(trials_path);
|
||||
|
||||
let best_path = ctx.out_dir.join("best.toml");
|
||||
write_best_toml(&best_path, ctx, &ranked.best)?;
|
||||
written.push(best_path);
|
||||
|
||||
if let Some(trace) = &ctx.best_trace {
|
||||
let trace_path = ctx.out_dir.join("best-trace.csv");
|
||||
write_trace_csv(&trace_path, trace)?;
|
||||
written.push(trace_path);
|
||||
}
|
||||
|
||||
let report_path = ctx.out_dir.join("report.md");
|
||||
write_markdown(&report_path, ctx, ranked)?;
|
||||
written.push(report_path);
|
||||
|
||||
let summary_path = ctx.out_dir.join("summary.json");
|
||||
write_summary(&summary_path, ctx, ranked)?;
|
||||
written.push(summary_path);
|
||||
|
||||
#[cfg(feature = "plot")]
|
||||
if let Some(trace) = &ctx.best_trace {
|
||||
let svg_path = ctx.out_dir.join("best.svg");
|
||||
crate::plot::write_step_svg(&svg_path, trace, &ctx.scenario)?;
|
||||
written.push(svg_path);
|
||||
}
|
||||
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// `trials.csv` 的一行。
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TrialRow {
|
||||
index: u64,
|
||||
phase: &'static str,
|
||||
kp: f32,
|
||||
ki: f32,
|
||||
kd: f32,
|
||||
feedforward: f32,
|
||||
cost: f64,
|
||||
verdict: &'static str,
|
||||
settled: bool,
|
||||
rise_time_s: f32,
|
||||
overshoot_pct: f32,
|
||||
settling_time_s: f32,
|
||||
ss_error_pct: f32,
|
||||
steady_rpm: f32,
|
||||
iae: f32,
|
||||
peak_effort_raw: f32,
|
||||
rms_effort_raw: f32,
|
||||
saturated_pct: f32,
|
||||
max_rpm: f32,
|
||||
}
|
||||
|
||||
impl TrialRow {
|
||||
fn from_trial(trial: &Trial) -> Self {
|
||||
let metrics = trial.score.metrics.unwrap_or_default();
|
||||
Self {
|
||||
index: trial.index,
|
||||
phase: trial.phase.label(),
|
||||
kp: trial.gains.kp,
|
||||
ki: trial.gains.ki,
|
||||
kd: trial.gains.kd,
|
||||
feedforward: trial.gains.feedforward,
|
||||
cost: trial.score.cost,
|
||||
verdict: trial.score.verdict.label(),
|
||||
settled: metrics.settled,
|
||||
rise_time_s: metrics.rise_time_s,
|
||||
overshoot_pct: metrics.overshoot_pct,
|
||||
settling_time_s: metrics.settling_time_s,
|
||||
ss_error_pct: metrics.ss_error_pct,
|
||||
steady_rpm: metrics.steady_rpm,
|
||||
iae: metrics.iae,
|
||||
peak_effort_raw: metrics.peak_effort_raw,
|
||||
rms_effort_raw: metrics.rms_effort_raw,
|
||||
saturated_pct: metrics.saturated_pct,
|
||||
max_rpm: metrics.max_rpm,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_trials_csv(path: &Path, trials: &[Trial]) -> Result<(), ReportError> {
|
||||
let mut writer = csv::Writer::from_path(path)?;
|
||||
for trial in trials {
|
||||
writer.serialize(TrialRow::from_trial(trial))?;
|
||||
}
|
||||
writer.flush().map_err(|e| io(path, e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 轨迹 CSV 的一行。
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TraceRow {
|
||||
t_s: f32,
|
||||
setpoint_rpm: f32,
|
||||
rpm: f32,
|
||||
effort_raw: i16,
|
||||
saturated: bool,
|
||||
}
|
||||
|
||||
fn write_trace_csv(path: &Path, trace: &Trace) -> Result<(), ReportError> {
|
||||
let mut writer = csv::Writer::from_path(path)?;
|
||||
for index in 0..trace.len() {
|
||||
writer.serialize(TraceRow {
|
||||
t_s: trace.t_s[index],
|
||||
setpoint_rpm: trace.setpoint_rpm[index],
|
||||
rpm: trace.rpm[index],
|
||||
effort_raw: trace.effort_raw[index],
|
||||
saturated: trace.saturated[index],
|
||||
})?;
|
||||
}
|
||||
writer.flush().map_err(|e| io(path, e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 可粘贴进既有 `[[motor]]` 段落的增益补丁。
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GainPatch {
|
||||
kp: f32,
|
||||
ki: f32,
|
||||
kd: f32,
|
||||
feedforward: f32,
|
||||
}
|
||||
|
||||
fn write_best_toml(path: &Path, ctx: &ReportContext, best: &Trial) -> Result<(), ReportError> {
|
||||
let gains_patch = GainPatch {
|
||||
kp: best.gains.kp,
|
||||
ki: best.gains.ki,
|
||||
kd: best.gains.kd,
|
||||
feedforward: best.gains.feedforward,
|
||||
};
|
||||
let mut text = String::new();
|
||||
writeln!(text, "# 由 chassis-tune 生成(seed={}, backend={})", ctx.config.seed, ctx.backend)
|
||||
.ok();
|
||||
writeln!(
|
||||
text,
|
||||
"# 轮子:{}|场景:{}|限流 {} raw",
|
||||
ctx.motor_name,
|
||||
crate::search::scenario_summary(&ctx.scenario),
|
||||
ctx.limits.current_limit_raw
|
||||
)
|
||||
.ok();
|
||||
writeln!(text, "# 参数空间:{}", ctx.space.describe()).ok();
|
||||
text.push_str("# 把下面四行粘进 chassis.toml 里对应的 [[motor]] 段落即可。\n");
|
||||
text.push_str(&toml::to_string(&gains_patch)?);
|
||||
std::fs::write(path, text).map_err(|e| io(path, e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `summary.json` 的结构。
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Summary<'a> {
|
||||
backend: &'a str,
|
||||
seed: u64,
|
||||
samples: usize,
|
||||
top_k: usize,
|
||||
solver: &'static str,
|
||||
evals: u64,
|
||||
ok_trials: usize,
|
||||
rejected_trials: usize,
|
||||
scenario: String,
|
||||
gain_space: String,
|
||||
limits: LimitSummary,
|
||||
thresholds: Thresholds,
|
||||
best: BestSummary,
|
||||
top: Vec<BestSummary>,
|
||||
notes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LimitSummary {
|
||||
current_limit_raw: i16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Thresholds {
|
||||
max_overshoot_pct: f32,
|
||||
max_ss_error_pct: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BestSummary {
|
||||
index: u64,
|
||||
phase: &'static str,
|
||||
kp: f32,
|
||||
ki: f32,
|
||||
kd: f32,
|
||||
feedforward: f32,
|
||||
cost: f64,
|
||||
verdict: &'static str,
|
||||
metrics: Option<MetricsSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MetricsSummary {
|
||||
rise_time_s: f32,
|
||||
overshoot_pct: f32,
|
||||
settling_time_s: f32,
|
||||
ss_error_pct: f32,
|
||||
steady_rpm: f32,
|
||||
iae: f32,
|
||||
rms_effort_raw: f32,
|
||||
saturated_pct: f32,
|
||||
max_rpm: f32,
|
||||
}
|
||||
|
||||
impl MetricsSummary {
|
||||
fn from_metrics(metrics: &StepMetrics) -> Self {
|
||||
Self {
|
||||
rise_time_s: metrics.rise_time_s,
|
||||
overshoot_pct: metrics.overshoot_pct,
|
||||
settling_time_s: metrics.settling_time_s,
|
||||
ss_error_pct: metrics.ss_error_pct,
|
||||
steady_rpm: metrics.steady_rpm,
|
||||
iae: metrics.iae,
|
||||
rms_effort_raw: metrics.rms_effort_raw,
|
||||
saturated_pct: metrics.saturated_pct,
|
||||
max_rpm: metrics.max_rpm,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn best_summary(trial: &Trial) -> BestSummary {
|
||||
BestSummary {
|
||||
index: trial.index,
|
||||
phase: trial.phase.label(),
|
||||
kp: trial.gains.kp,
|
||||
ki: trial.gains.ki,
|
||||
kd: trial.gains.kd,
|
||||
feedforward: trial.gains.feedforward,
|
||||
cost: trial.score.cost,
|
||||
verdict: trial.score.verdict.label(),
|
||||
metrics: trial.score.metrics.as_ref().map(MetricsSummary::from_metrics),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_summary(path: &Path, ctx: &ReportContext, ranked: &Ranked) -> Result<(), ReportError> {
|
||||
let mut notes = Vec::new();
|
||||
if !ranked.best.score.verdict.is_ok() {
|
||||
notes.push(format!(
|
||||
"没有任何候选达标:最优判定为 {}({})",
|
||||
ranked.best.score.verdict.label(),
|
||||
verdict_hint(ranked.best.score.verdict)
|
||||
));
|
||||
}
|
||||
if let Some(metrics) = ranked.best.score.metrics {
|
||||
if metrics.overshoot_pct > ctx.max_overshoot_pct {
|
||||
notes.push(format!(
|
||||
"最优解超调 {:.1}% 超过阈值 {:.1}%",
|
||||
metrics.overshoot_pct, ctx.max_overshoot_pct
|
||||
));
|
||||
}
|
||||
if metrics.ss_error_pct > ctx.max_ss_error_pct {
|
||||
notes.push(format!(
|
||||
"最优解稳态误差 {:.1}% 超过阈值 {:.1}%",
|
||||
metrics.ss_error_pct, ctx.max_ss_error_pct
|
||||
));
|
||||
}
|
||||
if metrics.saturated_pct > 5.0 {
|
||||
notes.push(format!("最优解饱和占比 {:.1}%,限流可能偏紧", metrics.saturated_pct));
|
||||
}
|
||||
}
|
||||
// 贴边界的警告排在"通用提醒"之前:它是本次搜索最具体的一条结论。
|
||||
notes.extend(ctx.space.boundary_hits(&ranked.best.gains));
|
||||
notes.push(
|
||||
"仿真整定依赖被控对象模型;上真机前先用 `validate` 在台架上复验(--backend bench)。"
|
||||
.into(),
|
||||
);
|
||||
|
||||
let summary = Summary {
|
||||
backend: &ctx.backend,
|
||||
seed: ctx.config.seed,
|
||||
samples: ctx.config.samples,
|
||||
top_k: ctx.config.top_k,
|
||||
solver: ctx.config.solver.name(),
|
||||
evals: ranked.evals,
|
||||
ok_trials: ranked.ok_count(),
|
||||
rejected_trials: ranked.rejected,
|
||||
scenario: crate::search::scenario_summary(&ctx.scenario),
|
||||
gain_space: ctx.space.describe(),
|
||||
limits: LimitSummary { current_limit_raw: ctx.limits.current_limit_raw },
|
||||
thresholds: Thresholds {
|
||||
max_overshoot_pct: ctx.max_overshoot_pct,
|
||||
max_ss_error_pct: ctx.max_ss_error_pct,
|
||||
},
|
||||
best: best_summary(&ranked.best),
|
||||
top: ranked.top(10).iter().map(best_summary).collect(),
|
||||
notes,
|
||||
};
|
||||
let text = serde_json::to_string_pretty(&summary)?;
|
||||
std::fs::write(path, text).map_err(|e| io(path, e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_markdown(path: &Path, ctx: &ReportContext, ranked: &Ranked) -> Result<(), ReportError> {
|
||||
let mut text = String::new();
|
||||
let best = &ranked.best;
|
||||
|
||||
writeln!(text, "# 速度环自动整定报告\n").ok();
|
||||
write_markdown_conclusion(&mut text, ctx, best);
|
||||
write_markdown_settings(&mut text, ctx, ranked);
|
||||
write_markdown_top(&mut text, ranked);
|
||||
write_markdown_artifacts(&mut text);
|
||||
write_markdown_notes(&mut text, ctx, best);
|
||||
|
||||
std::fs::write(path, text).map_err(|e| io(path, e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 结论段:最优增益、指标、验收、无解时的原因。
|
||||
fn write_markdown_conclusion(text: &mut String, ctx: &ReportContext, best: &Trial) {
|
||||
writeln!(text, "## 结论\n").ok();
|
||||
writeln!(
|
||||
text,
|
||||
"**kp = {:.3} ki = {:.3} kd = {:.3} feedforward = {:.3}**(成本 {:.4},判定 {})\n",
|
||||
best.gains.kp,
|
||||
best.gains.ki,
|
||||
best.gains.kd,
|
||||
best.gains.feedforward,
|
||||
best.cost(),
|
||||
best.score.verdict.label()
|
||||
)
|
||||
.ok();
|
||||
if let Some(metrics) = best.score.metrics {
|
||||
writeln!(
|
||||
text,
|
||||
"- 上升时间 {:.0} ms|超调 {:.1}%|调节时间 {:.0} ms|稳态误差 {:.2}%",
|
||||
metrics.rise_time_s * 1000.0,
|
||||
metrics.overshoot_pct,
|
||||
metrics.settling_time_s * 1000.0,
|
||||
metrics.ss_error_pct
|
||||
)
|
||||
.ok();
|
||||
writeln!(
|
||||
text,
|
||||
"- 峰值电流 {:.0} raw|RMS 电流 {:.0} raw|饱和 {:.1}%|峰值转速 {:.0} rpm",
|
||||
metrics.peak_effort_raw, metrics.rms_effort_raw, metrics.saturated_pct, metrics.max_rpm
|
||||
)
|
||||
.ok();
|
||||
writeln!(
|
||||
text,
|
||||
"- 验收:{}(阈值 超调 ≤ {:.1}%,稳态误差 ≤ {:.1}%)\n",
|
||||
if ctx.best_passes(&metrics) { "✓ 通过" } else { "✗ 未通过" },
|
||||
ctx.max_overshoot_pct,
|
||||
ctx.max_ss_error_pct
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
if !best.score.verdict.is_ok() {
|
||||
writeln!(
|
||||
text,
|
||||
"> ⚠ 没有任何候选达标(最优:{})。{}\n",
|
||||
best.score.verdict.label(),
|
||||
verdict_hint(best.score.verdict)
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置段:复现本次搜索所需的全部输入。
|
||||
fn write_markdown_settings(text: &mut String, ctx: &ReportContext, ranked: &Ranked) {
|
||||
writeln!(text, "## 设置\n").ok();
|
||||
writeln!(text, "| 项 | 值 |").ok();
|
||||
writeln!(text, "| --- | --- |").ok();
|
||||
writeln!(text, "| 后端 | `{}` |", ctx.backend).ok();
|
||||
writeln!(text, "| 场景 | `{}` |", crate::search::scenario_summary(&ctx.scenario)).ok();
|
||||
writeln!(text, "| 参数空间 | `{}` |", ctx.space.describe()).ok();
|
||||
writeln!(text, "| 限流 | {} raw |", ctx.limits.current_limit_raw).ok();
|
||||
writeln!(text, "| 种子 | {} |", ctx.config.seed).ok();
|
||||
writeln!(
|
||||
text,
|
||||
"| 搜索 | samples={}, top_k={}, solver={}, max_iters={} |",
|
||||
ctx.config.samples,
|
||||
ctx.config.top_k,
|
||||
ctx.config.solver.name(),
|
||||
ctx.config.max_iters
|
||||
)
|
||||
.ok();
|
||||
writeln!(
|
||||
text,
|
||||
"| 评估 | {} 次(达标 {} / 共 {}) |",
|
||||
ranked.evals,
|
||||
ranked.ok_count(),
|
||||
ranked.trials.len()
|
||||
)
|
||||
.ok();
|
||||
writeln!(text, "| 目标轮 | `{}` |\n", ctx.motor_name).ok();
|
||||
}
|
||||
|
||||
/// Top-10 表格。
|
||||
fn write_markdown_top(text: &mut String, ranked: &Ranked) {
|
||||
writeln!(text, "## Top 10\n").ok();
|
||||
writeln!(
|
||||
text,
|
||||
"| # | 阶段 | kp | ki | kd | ff | 成本 | 判定 | 超调 % | 稳态误差 % | 调节 ms |"
|
||||
)
|
||||
.ok();
|
||||
writeln!(text, "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |").ok();
|
||||
for (rank, trial) in ranked.top(10).iter().enumerate() {
|
||||
let metrics = trial.score.metrics.unwrap_or_default();
|
||||
writeln!(
|
||||
text,
|
||||
"| {} | {} | {:.3} | {:.3} | {:.3} | {:.3} | {:.4} | {} | {:.1} | {:.2} | {:.0} |",
|
||||
rank + 1,
|
||||
trial.phase.label(),
|
||||
trial.gains.kp,
|
||||
trial.gains.ki,
|
||||
trial.gains.kd,
|
||||
trial.gains.feedforward,
|
||||
trial.cost(),
|
||||
trial.score.verdict.label(),
|
||||
metrics.overshoot_pct,
|
||||
metrics.ss_error_pct,
|
||||
metrics.settling_time_s * 1000.0
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
writeln!(text).ok();
|
||||
}
|
||||
|
||||
/// 产物清单。
|
||||
fn write_markdown_artifacts(text: &mut String) {
|
||||
writeln!(text, "## 产物\n").ok();
|
||||
writeln!(text, "- `trials.csv`:全部候选(含未达标者与判定原因)").ok();
|
||||
writeln!(text, "- `best.toml`:可粘贴的 `[[motor]]` 增益片段").ok();
|
||||
writeln!(text, "- `best-trace.csv`:最优增益的完整轨迹").ok();
|
||||
writeln!(text, "- `hil-validation.csv`:真机重复性复验(仅 `--backend bench`)").ok();
|
||||
writeln!(text, "- `summary.json`:机器可读结果\n").ok();
|
||||
}
|
||||
|
||||
/// 注意事项:把"仿真整定不能替代实车复验"写进报告,而不是只写在 README 里。
|
||||
fn write_markdown_notes(text: &mut String, ctx: &ReportContext, best: &Trial) {
|
||||
let hits = ctx.space.boundary_hits(&best.gains);
|
||||
writeln!(text, "## 注意事项\n").ok();
|
||||
writeln!(text, "1. 仿真整定只保证在被控对象模型内最优;模型参数(摩擦 / 延迟)是量级估计,")
|
||||
.ok();
|
||||
writeln!(
|
||||
text,
|
||||
" 上真机前请用同一 `--scenario` 跑 `chassis-tune sweep --backend bench` 复验。"
|
||||
)
|
||||
.ok();
|
||||
writeln!(
|
||||
text,
|
||||
"2. 台架搜索会真的驱动轮子:必须架空或整车固定,且加 `--i-know-the-wheel-spins`。"
|
||||
)
|
||||
.ok();
|
||||
writeln!(
|
||||
text,
|
||||
"3. `current_limit` 属于安全约束,不参与搜索;它会钳住 PID 输出,也决定饱和判定。"
|
||||
)
|
||||
.ok();
|
||||
writeln!(text, "4. 复现本次结果:同一 `--seed` + 同一参数空间 → 逐位一致。").ok();
|
||||
for hit in &hits {
|
||||
writeln!(text, "- ⚠ 搜索落在参数空间边界:{hit}").ok();
|
||||
}
|
||||
writeln!(
|
||||
text,
|
||||
"5. 本模型不含测量噪声:仿真里更高的增益不会被噪声惩罚,因此最优解可能偏激进——\n 台架复验(`--backend bench`)是把它拉回现实的唯一手段。"
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
impl ReportContext {
|
||||
/// 一组指标是否满足验收阈值。
|
||||
#[must_use]
|
||||
pub fn best_passes(&self, metrics: &StepMetrics) -> bool {
|
||||
metrics.settled
|
||||
&& metrics.overshoot_pct <= self.max_overshoot_pct
|
||||
&& metrics.ss_error_pct <= self.max_ss_error_pct
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::{Evaluator, SimEvaluator};
|
||||
use crate::cost::{Objective, Weights};
|
||||
use crate::gains::GainSpace;
|
||||
use crate::search::{SearchConfig, sweep};
|
||||
use dji_esc_sim::{LoopLimits, PlantEnsemble, PlantParams};
|
||||
|
||||
fn ranked(name: &str) -> (GainSpace, ReportContext, Ranked) {
|
||||
let space = GainSpace::default_gm3508();
|
||||
let scenario = Scenario::step(1500.0, 1.0);
|
||||
let limits = LoopLimits::gm3508();
|
||||
let objective = Objective::new(Weights::default(), scenario, limits);
|
||||
let ensemble = PlantEnsemble::homogeneous(PlantParams::gm3508(), 1);
|
||||
let evaluator = SimEvaluator::new(ensemble, scenario, limits, objective, 0.0);
|
||||
let config = SearchConfig {
|
||||
seed: 3,
|
||||
samples: 48,
|
||||
top_k: 2,
|
||||
max_iters: 40,
|
||||
max_evals: 900,
|
||||
..SearchConfig::default()
|
||||
};
|
||||
let ranked = sweep(&space, &config, &evaluator, None).unwrap();
|
||||
let trace =
|
||||
dji_esc_sim::simulate(ranked.best.gains, &PlantParams::gm3508(), &scenario, limits)
|
||||
.unwrap();
|
||||
let ctx = ReportContext {
|
||||
out_dir: std::env::temp_dir().join(format!("chassis-tune-report-{name}")),
|
||||
space,
|
||||
config,
|
||||
scenario,
|
||||
limits,
|
||||
backend: evaluator.describe(),
|
||||
motor_name: "front_left".into(),
|
||||
max_overshoot_pct: 20.0,
|
||||
max_ss_error_pct: 5.0,
|
||||
best_trace: Some(trace),
|
||||
};
|
||||
(space, ctx, ranked)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_every_artifact() {
|
||||
let (_, ctx, ranked) = ranked("writes_every_artifact");
|
||||
let _ = std::fs::remove_dir_all(&ctx.out_dir);
|
||||
let written = write_reports(&ctx, &ranked).unwrap();
|
||||
let names: Vec<String> = written
|
||||
.iter()
|
||||
.map(|p| p.file_name().unwrap_or_default().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
for expected in ["trials.csv", "best.toml", "best-trace.csv", "report.md", "summary.json"] {
|
||||
assert!(names.iter().any(|n| n == expected), "missing {expected}: {names:?}");
|
||||
}
|
||||
for path in &written {
|
||||
assert!(path.exists(), "{} must exist", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trials_csv_has_a_stable_header_and_one_row_per_trial() {
|
||||
let (_, ctx, ranked) = ranked("trials_csv_has_a_stable_header_and_one_row_per_trial");
|
||||
write_reports(&ctx, &ranked).unwrap();
|
||||
let text = std::fs::read_to_string(ctx.out_dir.join("trials.csv")).unwrap();
|
||||
let mut lines = text.lines();
|
||||
let header = lines.next().unwrap();
|
||||
for column in ["index", "phase", "kp", "ki", "kd", "feedforward", "cost", "verdict"] {
|
||||
assert!(header.contains(column), "header missing {column}: {header}");
|
||||
}
|
||||
assert_eq!(lines.count(), ranked.trials.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_toml_parses_back_through_the_config_schema() {
|
||||
let (_, ctx, ranked) = ranked("best_toml_parses_back_through_the_config_schema");
|
||||
write_reports(&ctx, &ranked).unwrap();
|
||||
let text = std::fs::read_to_string(ctx.out_dir.join("best.toml")).unwrap();
|
||||
// 片段本体是合法 TOML:与其它段落拼起来即可被 chassis-config 读回。
|
||||
let patch = text
|
||||
.lines()
|
||||
.filter(|line| !line.trim_start().starts_with('#'))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let full = format!(
|
||||
"[can]\ninterface = \"can0\"\n[mecanum]\nhalf_length = 0.2\nhalf_width = 0.25\nwheel_radius = 0.0762\n\n[[motor]]\nname = \"front_left\"\nesc_id = 1\ndirection = 1\ngear_ratio = 19.203\n{patch}"
|
||||
);
|
||||
let mut motors = Vec::new();
|
||||
for index in 1..4 {
|
||||
motors.push(format!(
|
||||
"[[motor]]\nname = \"w{index}\"\nesc_id = {}\ndirection = 1\ngear_ratio = 19.203\n",
|
||||
index + 1
|
||||
));
|
||||
}
|
||||
let full = format!("{full}\n{}", motors.join("\n"));
|
||||
let config = chassis_config::Config::from_toml(&full).expect("best.toml must be readable");
|
||||
let gains = config.motors[0].velocity_gains();
|
||||
assert_eq!(gains, ranked.best.gains);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_json_is_machine_readable() {
|
||||
let (_, ctx, ranked) = ranked("summary_json_is_machine_readable");
|
||||
write_reports(&ctx, &ranked).unwrap();
|
||||
let text = std::fs::read_to_string(ctx.out_dir.join("summary.json")).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(value["seed"], serde_json::json!(3));
|
||||
assert_eq!(value["samples"], serde_json::json!(48));
|
||||
assert!(value["best"]["kp"].as_f64().unwrap() > 0.0);
|
||||
assert!(value["top"].as_array().unwrap().len() <= 10);
|
||||
assert!(!value["notes"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_mentions_the_gains_and_thresholds() {
|
||||
let (_, ctx, ranked) = ranked("markdown_mentions_the_gains_and_thresholds");
|
||||
write_reports(&ctx, &ranked).unwrap();
|
||||
let text = std::fs::read_to_string(ctx.out_dir.join("report.md")).unwrap();
|
||||
assert!(text.contains("kp ="), "{text}");
|
||||
assert!(text.contains("Top 10"), "{text}");
|
||||
assert!(text.contains("front_left"), "{text}");
|
||||
assert!(text.contains("注意事项"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_trace_csv_matches_the_trace_length() {
|
||||
let (_, ctx, ranked) = ranked("best_trace_csv_matches_the_trace_length");
|
||||
write_reports(&ctx, &ranked).unwrap();
|
||||
let text = std::fs::read_to_string(ctx.out_dir.join("best-trace.csv")).unwrap();
|
||||
let rows = text.lines().count();
|
||||
let expected = ctx.best_trace.as_ref().unwrap().len() + 1; // + 表头
|
||||
assert_eq!(rows, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_passes_respects_the_thresholds() {
|
||||
let (_, ctx, _) = ranked("t");
|
||||
let passing = StepMetrics {
|
||||
settled: true,
|
||||
overshoot_pct: 10.0,
|
||||
ss_error_pct: 1.0,
|
||||
..StepMetrics::default()
|
||||
};
|
||||
assert!(ctx.best_passes(&passing));
|
||||
let overshooting = StepMetrics { overshoot_pct: 50.0, ..passing };
|
||||
assert!(!ctx.best_passes(&overshooting));
|
||||
let unsettled = StepMetrics { settled: false, ..passing };
|
||||
assert!(!ctx.best_passes(&unsettled));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user