714 lines
24 KiB
Rust
714 lines
24 KiB
Rust
//! 报告与产物写出: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));
|
||
}
|
||
}
|