From af151525d13e0235615bfeec0c037fd5d7427bf2 Mon Sep 17 00:00:00 2001 From: Horldsence Date: Wed, 23 Sep 2026 12:26:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=9B=9B=E8=BD=AE?= =?UTF-8?q?=E5=AD=90=E7=8B=AC=E7=AB=8Bpid=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/tune.toml | 2 +- crates/bench/src/lib.rs | 18 +++++++++++++ crates/chassis-tune/src/main.rs | 9 +++++++ crates/experiment/src/lib.rs | 48 ++++++++++++++++++++++++++------- crates/tuning/src/lib.rs | 48 ++++++++++++++++++++++++++------- docs/pid/pid-summary.md | 26 ++++++++++++++++++ justfile | 7 ++--- 7 files changed, 134 insertions(+), 24 deletions(-) create mode 100644 docs/pid/pid-summary.md diff --git a/config/tune.toml b/config/tune.toml index 549e1e5..2bd6087 100644 --- a/config/tune.toml +++ b/config/tune.toml @@ -206,7 +206,7 @@ friction_pct = 0.5 max_rpm = 2000.0 max_temp_c = 60 trial_timeout_ms = 6000 -between_trials_ms = 500 +between_trials_ms = 1500 startup_timeout_ms = 5000 continue_on_abort = false # 同一组增益跑 3 次看重复性;离散度大说明参数贴着噪声,不该上车。 diff --git a/crates/bench/src/lib.rs b/crates/bench/src/lib.rs index fcafe8a..cc70db8 100644 --- a/crates/bench/src/lib.rs +++ b/crates/bench/src/lib.rs @@ -786,8 +786,10 @@ pub mod hil { let mut policy = MotorPolicy::new(); policy.configure(config.esc.esc_id, config.esc.current_limit, VelocityGains::ZERO); + let runtime_guard = runtime.enter(); let bus = EscBus::spawn(link, vec![config.esc.clone()], config.bus.clone(), policy) .map_err(|e| BenchError::Bus(e.to_string()))?; + drop(runtime_guard); let motor = bus .motor(esc_id) .ok_or_else(|| BenchError::Bus(format!("esc {esc_id} missing from the bus")))? @@ -938,6 +940,22 @@ pub mod hil { open_link("ignored", 2_000_000, crate::CanSpeed::M1, Some("can-not-real")); assert!(missing_interface.is_err(), "不存在的 CAN 接口必须报错"); } + + #[test] + fn evaluator_spawns_bus_without_an_outer_tokio_runtime() { + let (link, peer) = can::CanLink::pair(8); + // 测试只关心 runtime 启动;立刻关闭对端,避免假链路堵住 1kHz 命令发送。 + drop(peer); + let mut config = config(); + config.startup_timeout = Duration::from_millis(1); + + let result = BenchEvaluator::open(link, config); + + assert!( + matches!(result, Err(BenchError::NoFeedback { esc_id: 1, .. })), + "应当在自有 runtime 中启动总线并因无反馈返回错误" + ); + } } } diff --git a/crates/chassis-tune/src/main.rs b/crates/chassis-tune/src/main.rs index 55170b9..5102e70 100644 --- a/crates/chassis-tune/src/main.rs +++ b/crates/chassis-tune/src/main.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; pub mod report { //! 报告与产物写出:CSV / TOML / JSON / Markdown(可选 SVG)。 @@ -1656,6 +1657,7 @@ struct TuningBench { continue_on_abort: bool, best: Mutex>, aborts: Mutex>, + stopped: AtomicBool, describe: String, } @@ -1682,6 +1684,7 @@ impl TuningBench { continue_on_abort, best: Mutex::new(None), aborts: Mutex::new(Vec::new()), + stopped: AtomicBool::new(false), describe, }) } @@ -1701,12 +1704,18 @@ impl TuningBench { impl Evaluator for TuningBench { fn evaluate(&self, gains: &VelocityGains) -> Score { + if self.stopped.load(Ordering::Relaxed) { + return Score::penalized(Verdict::Rejected); + } 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)); + if !self.continue_on_abort { + self.stopped.store(true, Ordering::Relaxed); + } Score::penalized(verdict_for(&reason)) } else { match experiment::StepMetrics::from_trace(&outcome.trace, &self.scenario()) { diff --git a/crates/experiment/src/lib.rs b/crates/experiment/src/lib.rs index aa9c88c..cfc09fb 100644 --- a/crates/experiment/src/lib.rs +++ b/crates/experiment/src/lib.rs @@ -484,13 +484,26 @@ pub mod metrics { use crate::{Scenario, Trace}; - /// 稳态判据的相对带宽(±2% of target)。 - pub const SETTLING_BAND: f32 = 0.02; + /// 稳态判据的相对带宽(±5% of target)。 + /// + /// C620 的转速反馈是整数 rpm,空载低速时测量抖动本身就会超过 ±2%; + /// 真机台架若沿用 ±2% 会把几乎每一组正常 PID 都误判为 `no-settle`。 + pub const SETTLING_BAND: f32 = 0.05; + /// 判定“基本收敛”时使用的较宽相对带宽(±10% of target)。 + /// + /// 调节时间仍按 ±5% 计算;这里只回答“稳态阶段是否大体跟住”,避免真机反馈 + /// 的少量量化尖峰把整组可用增益判成未收敛。 + pub const SETTLED_BAND: f32 = 0.10; /// 稳态窗口占轨迹总时长的比例(从 75% 处开始)。 /// /// 与场景自带的 `steady_start_s()` 取**更晚**者:阶跃场景没有显式斜坡, /// 若不设下限就会把整条上升段也算进"稳态",任何真实响应都会被判成未收敛。 pub const STEADY_FRACTION: f32 = 0.75; + /// 稳态窗口允许的离带采样占比。 + /// + /// 真机转速反馈存在量化与负载扰动,要求稳态窗口内**每一个采样**都位于带内 + /// 会把正常的小幅抖振误判成未收敛;允许少量孤立离带样本更符合台架数据。 + pub const MAX_STEADY_EXCURSION_FRACTION: f32 = 0.10; /// 指标最少需要的采样数(样本太少时一切统计都无意义)。 pub const MIN_SAMPLES: usize = 8; @@ -501,9 +514,9 @@ pub mod metrics { pub rise_time_s: f32, /// 超调量(%,相对目标;负值截为 0)。 pub overshoot_pct: f32, - /// 调节时间:最后一次离开 ±2% 带的时刻(秒)。 + /// 调节时间:最后一次离开 ±5% 带的时刻(秒)。 pub settling_time_s: f32, - /// 是否在稳态窗口内保持 ±2% 带(即真正收敛)。 + /// 是否在稳态窗口内基本保持 ±10% 带(允许少量测量抖动离带)。 pub settled: bool, /// 稳态误差(%,相对目标)。 pub ss_error_pct: f32, @@ -581,8 +594,13 @@ pub mod metrics { settling_time_s = t; } } - // 收敛 = 最后一次离开带宽的时刻已经进入稳态窗口(此后一直留在带内)。 - let settled = settling_time_s <= steady_start && !window.is_empty(); + // 收敛 = 稳态窗口内绝大多数采样位于带内;孤立的反馈尖峰不否决整组增益。 + let settled_band = SETTLED_BAND * magnitude; + let out_of_band = + window.rpm().iter().filter(|rpm| (**rpm - target).abs() > settled_band).count(); + let excursion_fraction = + if window.is_empty() { 1.0 } else { out_of_band as f32 / window.len() as f32 }; + let settled = excursion_fraction <= MAX_STEADY_EXCURSION_FRACTION; // 误差积分(全过程,按 dt 累加)。 let mut abs_error_area = 0.0f32; @@ -678,11 +696,9 @@ pub mod metrics { #[test] fn steady_state_offset_shows_up() { - let trace = synthetic(0.02, 0.0, 50.0); + let trace = synthetic(0.02, 0.0, 60.0); let metrics = StepMetrics::from_trace(&trace, &scenario()).unwrap(); - assert!((metrics.ss_error_pct - 5.0).abs() < 1.0, "ss = {}", metrics.ss_error_pct); - // 5% 的末端偏差超出 ±2% 带 → 不算收敛。 - assert!(!metrics.settled); + assert!((metrics.ss_error_pct - 6.0).abs() < 1.0, "ss = {}", metrics.ss_error_pct); } #[test] @@ -694,6 +710,18 @@ pub mod metrics { assert!(metrics.ss_error_pct > 2.0); } + #[test] + fn isolated_feedback_excursions_do_not_reject_a_settled_response() { + let mut trace = synthetic(0.02, 0.0, 0.0); + let target = scenario().target_rpm; + let index = trace.t_s.iter().position(|t_s| *t_s > 0.75).unwrap(); + // 稳态窗口内制造一个孤立测量尖峰。 + trace.rpm[index] = target * 1.12; + + let metrics = StepMetrics::from_trace(&trace, &scenario()).unwrap(); + assert!(metrics.settled, "少量孤立反馈尖峰不应否决稳定响应"); + } + #[test] fn effort_statistics_and_saturation_are_reported() { let mut trace = synthetic(0.02, 0.0, 0.0); diff --git a/crates/tuning/src/lib.rs b/crates/tuning/src/lib.rs index 8fd9cc9..96e458b 100644 --- a/crates/tuning/src/lib.rs +++ b/crates/tuning/src/lib.rs @@ -704,10 +704,13 @@ pub mod cost { } impl Score { - /// 罚分结果。 + /// 没有可用指标时的罚分结果。 + /// + /// 它必须高于 [`Objective::rejected_cost`] 产生的指标罚值,否则预算耗尽、 + /// 安全停机等“未评估”占位结果会反过来击败真实测得的失败候选。 #[must_use] pub fn penalized(verdict: Verdict) -> Self { - Self { cost: PENALTY, verdict, metrics: None } + Self { cost: PENALTY * 2.0, verdict, metrics: None } } } @@ -757,20 +760,29 @@ pub mod cost { pub fn score_metrics(&self, metrics: StepMetrics) -> Score { if metrics.max_rpm > self.max_rpm { return Score { - cost: PENALTY, + cost: self.rejected_cost(&metrics) + + f64::from(metrics.max_rpm / self.max_rpm - 1.0).max(0.0) * 10.0, verdict: Verdict::Overspeed, metrics: Some(metrics), }; } if metrics.saturated_pct / 100.0 > self.max_saturated_fraction { return Score { - cost: PENALTY, + cost: self.rejected_cost(&metrics) + + (f64::from(metrics.saturated_pct) / 100.0 + - f64::from(self.max_saturated_fraction)) + .max(0.0) + * 10.0, verdict: Verdict::Saturated, metrics: Some(metrics), }; } if !metrics.settled { - return Score { cost: PENALTY, verdict: Verdict::NoSettle, metrics: Some(metrics) }; + return Score { + cost: self.rejected_cost(&metrics), + verdict: Verdict::NoSettle, + metrics: Some(metrics), + }; } Score { cost: self.weighted_cost(&metrics), @@ -779,6 +791,14 @@ pub mod cost { } } + /// 硬约束失败时的可比较罚值。 + /// + /// 所有失败解仍高于 [`PENALTY`],但保留指标残差,避免搜索面对一整片完全 + /// 同值的“罚值平原”而无法排序、精修或选出最接近可用的候选。 + fn rejected_cost(&self, metrics: &StepMetrics) -> f64 { + PENALTY + self.weighted_cost(metrics).max(0.0) + } + /// 归一化加权成本。 #[must_use] pub fn weighted_cost(&self, metrics: &StepMetrics) -> f64 { @@ -844,7 +864,15 @@ pub mod cost { simulate(weak, &PlantParams::gm3508(), &scenario, LoopLimits::gm3508()).unwrap(); let score = objective(scenario).score(&trace); assert_eq!(score.verdict, Verdict::NoSettle); - assert!((score.cost - PENALTY).abs() < 1.0); + assert!( + score.cost >= PENALTY && score.cost < PENALTY + 100.0, + "失败解应保留可比较的指标残差,cost = {}", + score.cost + ); + assert!( + Score::penalized(Verdict::Rejected).cost > score.cost, + "未评估占位结果不得击败真实测得的失败候选" + ); } #[test] @@ -2841,8 +2869,8 @@ pub mod search { //! //! # 为什么是两阶段 //! - //! 成本函数有一大片"罚值平原"(不收敛 / 饱和的候选成本都等于 `PENALTY`), - //! 纯局部搜索会陷在平原上;而纯网格搜索在四个数量级跨度上需要天文数字的 + //! 失败候选位于 `PENALTY` 以上的罚值区带,并携带连续的指标残差;纯局部搜索 + //! 仍难跨过成功 / 失败边界,而纯网格搜索在四个数量级跨度上需要天文数字的 //! 评估次数。两阶段各取所长: //! //! 1. **粗扫**:Latin Hypercube 分层采样 + `rayon` 并行评估,用有限次数把 @@ -3133,12 +3161,12 @@ pub mod search { fn cost(&self, param: &Self::Param) -> Result { let coords: [f64; AXIS_COUNT] = match param.as_slice() { [a, b, c, d] => [*a, *b, *c, *d], - _ => return Ok(crate::cost::PENALTY), + _ => return Ok(crate::cost::PENALTY * 2.0), }; let gains = self.space.from_coords(coords); let cost = match self.budget.take() { Some(_) => self.evaluator.evaluate(&gains).cost, - None => crate::cost::PENALTY, + None => crate::cost::PENALTY * 2.0, }; if cost.is_finite() { self.tracker.record(cost, coords); diff --git a/docs/pid/pid-summary.md b/docs/pid/pid-summary.md new file mode 100644 index 0000000..dc98d6e --- /dev/null +++ b/docs/pid/pid-summary.md @@ -0,0 +1,26 @@ +# 空载台架 PID 整定结果 + +测试条件:轮子架空,CANable2 `/dev/ttyACM0`,CAN 1 Mbps,目标转子转速 800 rpm, +限流 1500 raw,`ramp-and-hold` 300 ms + 1700 ms,试验间隔 1500 ms。 + +| 电调 | 轮子 | direction | kp | ki | kd | ff | 最终冒烟超调 | 稳态误差 | 结果 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| 1 | front_left | +1 | 0.985 | 2.136 | 0.068 | 0 | 5.1% | 0.00% | 通过 | +| 2 | front_right | -1 | 0.985 | 2.136 | 0.068 | 0 | 6.2% | 0.75% | 通过 | +| 3 | rear_left | -1 | 0.750 | 1.200 | 0.030 | 0 | 7.6% | 1.04% | 通过 | +| 4 | rear_right | -1 | 1.139 | 2.199 | 0.044 | 0 | 21.8% | 1.61% | 通过 | + +## 调试结论 + +- ESC3 的安装方向实测应为 `-1`;使用 `+1` 时闭环方向错误,会出现大幅超调和重复性崩溃。 +- 空载必须保持 `feedforward = 0`。 +- 真机反馈有明显量化抖动,整定指标改为 ±5% 调节带、稳态窗口允许少量离带采样, + 并以 ±10% 带判断“基本收敛”。 +- 默认限流从 12000 raw 收紧到台架 1500 raw;搜索遇到过速事件后立即停止后续驱动。 +- `tune-bench` 现在交叉编译 armv7 musl 产物,避免把宿主机二进制推到板端。 + +## 产物 + +- `esc1/`、`esc2/`、`esc4/`:自动搜索报告、轨迹、Top 候选和重复性复验。 +- `esc3/`:方向修正与保守增益复验摘要。 +- `chassis.tuned.toml`:四轮完整配置。 diff --git a/justfile b/justfile index a607f3f..66c638e 100644 --- a/justfile +++ b/justfile @@ -75,7 +75,8 @@ tune-write config="config/chassis.toml.example" motor="front_left" out="target/t cargo run --release -p chassis-tune -- sweep --config {{config}} --motor {{motor}} --write-config {{out}} --samples 256 --out-dir target/tune tune-bench board="root@192.168.2.2" device="/dev/ttyACM0" esc_id="1" samples="24" preset="bench_noload" opts="": - cargo build --release -p chassis-tune - scp -q target/release/chassis-tune {{board}}:/tmp/chassis-tune + rustup target add armv7-unknown-linux-musleabihf + cargo build --release -p chassis-tune --target armv7-unknown-linux-musleabihf + scp -q target/armv7-unknown-linux-musleabihf/release/chassis-tune {{board}}:/tmp/chassis-tune scp -q config/tune.toml {{board}}:/tmp/tune.toml - ssh {{board}} "/tmp/chassis-tune sweep --backend bench --preset /tmp/tune.toml --preset-name {{preset}} --device {{device}} --esc-id {{esc_id}} --samples {{samples}} --top-k 3 --i-know-the-wheel-spins {{opts}}" + ssh {{board}} "/tmp/chassis-tune sweep --backend bench --preset /tmp/tune.toml --preset-name {{preset}} --device {{device}} --esc-id {{esc_id}} --current-limit 1500 --samples {{samples}} --top-k 3 --i-know-the-wheel-spins {{opts}}"