#![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, 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::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, 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: 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 = 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)); } } } #[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(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(" 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, // ---------------- 超参数预设 ---------------- /// 超参数预设文件(TOML)。缺省时若给了 `--preset-name` 则用 `config/tune.toml` #[arg(long, value_name = "FILE")] preset: Option, /// 预设名(缺省取文件里的 `default_preset`) #[arg(long, value_name = "NAME")] preset_name: Option, // ---------------- 参数空间(覆盖预设) ---------------- /// kp 范围,如 `0.5..30`(内置默认 `0.5..30`) #[arg(long, value_name = "MIN..MAX")] kp: Option, /// ki 范围,如 `0.1..100`(内置默认 `0.1..100`) #[arg(long, value_name = "MIN..MAX")] ki: Option, /// kd 范围(内置默认 `0.01..3`) #[arg(long, value_name = "MIN..MAX")] kd: Option, /// 前馈范围(raw/rpm;内置默认 `0.01..2.5`) #[arg(long, value_name = "MIN..MAX")] feedforward: Option, /// kp 刻度(内置默认 `log`) #[arg(long, value_enum)] kp_scale: Option, /// ki 刻度(`log0` 允许精确取 0;内置默认 `log0`) #[arg(long, value_enum)] ki_scale: Option, /// kd 刻度(内置默认 `log0`) #[arg(long, value_enum)] kd_scale: Option, /// 前馈刻度(内置默认 `log0`) #[arg(long, value_enum)] feedforward_scale: Option, // ---------------- 场景(覆盖预设) ---------------- /// 目标转子转速(rpm;内置默认 1500) #[arg(long)] target_rpm: Option, /// 场景类型(内置默认 `ramp-and-hold`) #[arg(long, value_enum)] scenario: Option, /// 斜坡时间(毫秒;内置默认 200) #[arg(long)] ramp_ms: Option, /// 保持时间(毫秒;内置默认 1800) #[arg(long)] hold_ms: Option, /// 阶梯级数(`--scenario staircase`;内置默认 4) #[arg(long)] stair_levels: Option, /// 采样 / 控制节拍(微秒;内置默认 1000) #[arg(long)] dt_us: Option, /// 电流限幅(raw;同时是 PID 输出钳位点)。缺省取 `--config` 里该电机的值,否则 12000 #[arg(long)] current_limit: Option, /// 电调型号。缺省取 `--config` 里该电机的值,否则 C620 #[arg(long, value_enum)] model: Option, /// 现有 `chassis.toml`:用作机械 / 安全参数的来源(电流限幅、电调 ID、方向、减速比) #[arg(long)] config: Option, /// 把整定结果写回一份配置(需同时给 `--config`;只改写 kp/ki/kd/feedforward) #[arg(long)] write_config: Option, // ---------------- 成本权重(覆盖预设) ---------------- /// 调节时间权重(内置默认 1.0) #[arg(long)] w_settle: Option, /// 超调权重(内置默认 1.0) #[arg(long)] w_overshoot: Option, /// IAE 权重(内置默认 0.5) #[arg(long)] w_iae: Option, /// 电流(RMS)权重(内置默认 0.1) #[arg(long)] w_effort: Option, // ---------------- 验收阈值(覆盖预设) ---------------- /// 允许的最大超调(%;内置默认 20) #[arg(long)] max_overshoot: Option, /// 允许的最大稳态误差(%;内置默认 5) #[arg(long)] max_ss_error: Option, // ---------------- 搜索(覆盖预设) ---------------- /// 随机种子(同 seed 结果逐位一致;内置默认 20260101) #[arg(long)] seed: Option, /// 粗扫样本数(内置默认 512) #[arg(long)] samples: Option, /// 进入精修的种子数(内置默认 8) #[arg(long)] top_k: Option, /// 精修求解器(内置默认 `nelder-mead`) #[arg(long, value_enum)] solver: Option, /// 每个起点的最大迭代数(内置默认 200) #[arg(long)] max_iters: Option, /// PSO 粒子数(`--solver pso`;内置默认 24) #[arg(long)] particles: Option, /// 评估预算上限(内置默认 4000) #[arg(long)] max_evals: Option, /// 线程数(0 = rayon 默认;真机后端强制 1;内置默认 0) #[arg(long)] threads: Option, // ---------------- 真机安全包络(覆盖预设) ---------------- /// 过速保护上限(rpm;内置默认 3000) #[arg(long)] max_rpm: Option, /// 温度保护上限(℃;内置默认 70) #[arg(long)] max_temp_c: Option, /// 单次试验超时(毫秒;内置默认 6000) #[arg(long)] trial_timeout_ms: Option, /// 试验之间的静默期(毫秒;内置默认 500) #[arg(long)] between_trials_ms: Option, /// 启动时等待反馈的超时(毫秒;内置默认 5000) #[arg(long)] startup_timeout_ms: Option, /// 安全事件后继续搜索(默认立即收工) #[arg(long)] continue_on_abort: bool, /// 最优增益的复验次数(写入 hil-validation.csv;内置默认 3) #[arg(long)] validate_runs: Option, // ---------------- 输出 ---------------- /// 产物目录 #[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, /// 集成成员数(域随机化;1 = 只用名义模型;内置默认 8) #[arg(long)] ensemble: Option, /// 鲁棒权重:0 = 只看均值,1 = 只看最坏成员(内置默认 0.5) #[arg(long)] robust_weight: Option, /// 抖动幅度,如 `gain=0.2,tau=0.3,delay=3,friction=0.5`(内置默认同左) #[arg(long)] jitter: Option, } /// 真机接线参数(**不参与搜索,也不受预设影响**)。 #[derive(Debug, Clone, ClapArgs)] struct BenchArgs { /// slcan 串口设备 #[arg(long, default_value = "/dev/ttyACM0")] device: String, /// SocketCAN 接口名(给了它就走 SocketCAN,忽略 --device) #[arg(long)] interface: Option, /// 串口波特率 #[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, /// 安装方向 +1 / -1(缺省取 `--config`,否则 1) #[arg(long, allow_hyphen_values = true)] direction: Option, /// 减速比(转子 : 输出轴;缺省取 `--config`,否则 19.203) #[arg(long)] gear_ratio: Option, /// **明确确认**:轮子会真的转起来(架空或整车固定、急停可及) #[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 for ScaleSpec { fn from(value: ScaleArg) -> Self { match value { ScaleArg::Linear => Self::Linear, ScaleArg::Log => Self::Log, ScaleArg::Log0 => Self::Log0, } } } impl From 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 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 for PlantSpec { fn from(value: PlantArg) -> Self { match value { PlantArg::Gm3508 => Self::Gm3508, PlantArg::Gm3508Bench => Self::Gm3508Bench, } } } impl From 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 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 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 { 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, /// 整定结果写回路径。 write_config: Option, /// 产物目录。 out_dir: PathBuf, /// 目标轮名。 motor: String, /// 打印 JSON 摘要。 json: bool, /// 电流限幅覆盖(缺省取 `chassis.toml`,再缺省 12000)。 current_limit: Option, /// 电调型号覆盖。 model: Option, /// 真机接线参数。 bench: BenchArgs, /// 超参数来源描述(打印与报告用)。 source: String, // ---- 超参数(已解析 / 已校验) ---- /// 解算后的超参数。 tuning: Tuning, /// 真机安全包络。 envelope: BenchEnvelope, } impl CommonArgs { /// 解算超参数:**内置默认 → 预设文件 → 显式 CLI**。 /// /// # Errors /// /// 预设文件读取 / 解析失败、预设名不存在、区间 / 阈值 / 预算非法、 /// 或预设路径缺失时返回错误。 fn resolve(&self) -> Result { 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 { 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 ", 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> { 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, scale: Option| { 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`。 /// 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::() / 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>, aborts: Mutex>, describe: String, } impl TuningBench { fn open( link: can::CanLink, config: BenchConfig, objective: Objective, continue_on_abort: bool, ) -> Result { 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 { self.best .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .as_ref() .map(|(_, trace)| trace.clone()) } fn aborts(&self) -> Vec { 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), Bench(Box), } 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, } /// 解析机械 / 安全参数:显式 CLI 覆盖 `--config`,`--config` 覆盖内置默认。 /// /// 这些参数**不参与搜索**(它们是安全与机械约束),但必须来自同一份配置—— /// 否则"整定工具用的限流"和"守护进程用的限流"会悄悄不一致。 fn hardware(settings: &Settings) -> Result { 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::>().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 { 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 { 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 ", args.preset.display()); Ok(ExitCode::SUCCESS) } // ---------------------------------------------------------------- sweep fn sweep_cmd(settings: &Settings) -> Result { 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 { 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 { 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 { parts[index] .parse::() .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 { 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()); } }