//! 报告与产物写出: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, 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, } /// 写出全部产物,返回写出的文件列表。 /// /// # Errors /// /// 目录创建、文件写入或序列化失败时返回 [`ReportError`]。 pub fn write_reports(ctx: &ReportContext, ranked: &Ranked) -> Result, 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, notes: Vec, } #[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, } #[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 = 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::>() .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)); } }