feat: 完成四轮子独立pid调试

This commit is contained in:
Horldsence
2026-09-23 12:26:07 +08:00
parent f8aeafdfc2
commit af151525d1
7 changed files with 134 additions and 24 deletions
+1 -1
View File
@@ -206,7 +206,7 @@ friction_pct = 0.5
max_rpm = 2000.0 max_rpm = 2000.0
max_temp_c = 60 max_temp_c = 60
trial_timeout_ms = 6000 trial_timeout_ms = 6000
between_trials_ms = 500 between_trials_ms = 1500
startup_timeout_ms = 5000 startup_timeout_ms = 5000
continue_on_abort = false continue_on_abort = false
# 同一组增益跑 3 次看重复性;离散度大说明参数贴着噪声,不该上车。 # 同一组增益跑 3 次看重复性;离散度大说明参数贴着噪声,不该上车。
+18
View File
@@ -786,8 +786,10 @@ pub mod hil {
let mut policy = MotorPolicy::new(); let mut policy = MotorPolicy::new();
policy.configure(config.esc.esc_id, config.esc.current_limit, VelocityGains::ZERO); 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) let bus = EscBus::spawn(link, vec![config.esc.clone()], config.bus.clone(), policy)
.map_err(|e| BenchError::Bus(e.to_string()))?; .map_err(|e| BenchError::Bus(e.to_string()))?;
drop(runtime_guard);
let motor = bus let motor = bus
.motor(esc_id) .motor(esc_id)
.ok_or_else(|| BenchError::Bus(format!("esc {esc_id} missing from the bus")))? .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")); open_link("ignored", 2_000_000, crate::CanSpeed::M1, Some("can-not-real"));
assert!(missing_interface.is_err(), "不存在的 CAN 接口必须报错"); 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 中启动总线并因无反馈返回错误"
);
}
} }
} }
+9
View File
@@ -1,6 +1,7 @@
#![allow(clippy::unwrap_used, clippy::expect_used)] #![allow(clippy::unwrap_used, clippy::expect_used)]
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
pub mod report { pub mod report {
//! 报告与产物写出:CSV / TOML / JSON / Markdown(可选 SVG)。 //! 报告与产物写出:CSV / TOML / JSON / Markdown(可选 SVG)。
@@ -1656,6 +1657,7 @@ struct TuningBench {
continue_on_abort: bool, continue_on_abort: bool,
best: Mutex<Option<(f64, experiment::Trace)>>, best: Mutex<Option<(f64, experiment::Trace)>>,
aborts: Mutex<Vec<String>>, aborts: Mutex<Vec<String>>,
stopped: AtomicBool,
describe: String, describe: String,
} }
@@ -1682,6 +1684,7 @@ impl TuningBench {
continue_on_abort, continue_on_abort,
best: Mutex::new(None), best: Mutex::new(None),
aborts: Mutex::new(Vec::new()), aborts: Mutex::new(Vec::new()),
stopped: AtomicBool::new(false),
describe, describe,
}) })
} }
@@ -1701,12 +1704,18 @@ impl TuningBench {
impl Evaluator for TuningBench { impl Evaluator for TuningBench {
fn evaluate(&self, gains: &VelocityGains) -> Score { 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 outcome = self.runner.run_trial(gains);
let score = if let Some(reason) = outcome.abort { let score = if let Some(reason) = outcome.abort {
self.aborts self.aborts
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
.push(format!("{}: {reason}", self.describe)); .push(format!("{}: {reason}", self.describe));
if !self.continue_on_abort {
self.stopped.store(true, Ordering::Relaxed);
}
Score::penalized(verdict_for(&reason)) Score::penalized(verdict_for(&reason))
} else { } else {
match experiment::StepMetrics::from_trace(&outcome.trace, &self.scenario()) { match experiment::StepMetrics::from_trace(&outcome.trace, &self.scenario()) {
+38 -10
View File
@@ -484,13 +484,26 @@ pub mod metrics {
use crate::{Scenario, Trace}; use crate::{Scenario, Trace};
/// 稳态判据的相对带宽(±2% of target)。 /// 稳态判据的相对带宽(±5% of target)。
pub const SETTLING_BAND: f32 = 0.02; ///
/// 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% 处开始)。 /// 稳态窗口占轨迹总时长的比例(从 75% 处开始)。
/// ///
/// 与场景自带的 `steady_start_s()` 取**更晚**者:阶跃场景没有显式斜坡, /// 与场景自带的 `steady_start_s()` 取**更晚**者:阶跃场景没有显式斜坡,
/// 若不设下限就会把整条上升段也算进"稳态",任何真实响应都会被判成未收敛。 /// 若不设下限就会把整条上升段也算进"稳态",任何真实响应都会被判成未收敛。
pub const STEADY_FRACTION: f32 = 0.75; pub const STEADY_FRACTION: f32 = 0.75;
/// 稳态窗口允许的离带采样占比。
///
/// 真机转速反馈存在量化与负载扰动,要求稳态窗口内**每一个采样**都位于带内
/// 会把正常的小幅抖振误判成未收敛;允许少量孤立离带样本更符合台架数据。
pub const MAX_STEADY_EXCURSION_FRACTION: f32 = 0.10;
/// 指标最少需要的采样数(样本太少时一切统计都无意义)。 /// 指标最少需要的采样数(样本太少时一切统计都无意义)。
pub const MIN_SAMPLES: usize = 8; pub const MIN_SAMPLES: usize = 8;
@@ -501,9 +514,9 @@ pub mod metrics {
pub rise_time_s: f32, pub rise_time_s: f32,
/// 超调量(%,相对目标;负值截为 0)。 /// 超调量(%,相对目标;负值截为 0)。
pub overshoot_pct: f32, pub overshoot_pct: f32,
/// 调节时间:最后一次离开 ±2% 带的时刻(秒)。 /// 调节时间:最后一次离开 ±5% 带的时刻(秒)。
pub settling_time_s: f32, pub settling_time_s: f32,
/// 是否在稳态窗口内保持 ±2% 带(即真正收敛)。 /// 是否在稳态窗口内基本保持 ±10% 带(允许少量测量抖动离带)。
pub settled: bool, pub settled: bool,
/// 稳态误差(%,相对目标)。 /// 稳态误差(%,相对目标)。
pub ss_error_pct: f32, pub ss_error_pct: f32,
@@ -581,8 +594,13 @@ pub mod metrics {
settling_time_s = t; 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 累加)。 // 误差积分(全过程,按 dt 累加)。
let mut abs_error_area = 0.0f32; let mut abs_error_area = 0.0f32;
@@ -678,11 +696,9 @@ pub mod metrics {
#[test] #[test]
fn steady_state_offset_shows_up() { 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(); let metrics = StepMetrics::from_trace(&trace, &scenario()).unwrap();
assert!((metrics.ss_error_pct - 5.0).abs() < 1.0, "ss = {}", metrics.ss_error_pct); assert!((metrics.ss_error_pct - 6.0).abs() < 1.0, "ss = {}", metrics.ss_error_pct);
// 5% 的末端偏差超出 ±2% 带 → 不算收敛。
assert!(!metrics.settled);
} }
#[test] #[test]
@@ -694,6 +710,18 @@ pub mod metrics {
assert!(metrics.ss_error_pct > 2.0); 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] #[test]
fn effort_statistics_and_saturation_are_reported() { fn effort_statistics_and_saturation_are_reported() {
let mut trace = synthetic(0.02, 0.0, 0.0); let mut trace = synthetic(0.02, 0.0, 0.0);
+38 -10
View File
@@ -704,10 +704,13 @@ pub mod cost {
} }
impl Score { impl Score {
/// 罚分结果。 /// 没有可用指标时的罚分结果。
///
/// 它必须高于 [`Objective::rejected_cost`] 产生的指标罚值,否则预算耗尽、
/// 安全停机等“未评估”占位结果会反过来击败真实测得的失败候选。
#[must_use] #[must_use]
pub fn penalized(verdict: Verdict) -> Self { 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 { pub fn score_metrics(&self, metrics: StepMetrics) -> Score {
if metrics.max_rpm > self.max_rpm { if metrics.max_rpm > self.max_rpm {
return Score { 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, verdict: Verdict::Overspeed,
metrics: Some(metrics), metrics: Some(metrics),
}; };
} }
if metrics.saturated_pct / 100.0 > self.max_saturated_fraction { if metrics.saturated_pct / 100.0 > self.max_saturated_fraction {
return Score { 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, verdict: Verdict::Saturated,
metrics: Some(metrics), metrics: Some(metrics),
}; };
} }
if !metrics.settled { 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 { Score {
cost: self.weighted_cost(&metrics), 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] #[must_use]
pub fn weighted_cost(&self, metrics: &StepMetrics) -> f64 { pub fn weighted_cost(&self, metrics: &StepMetrics) -> f64 {
@@ -844,7 +864,15 @@ pub mod cost {
simulate(weak, &PlantParams::gm3508(), &scenario, LoopLimits::gm3508()).unwrap(); simulate(weak, &PlantParams::gm3508(), &scenario, LoopLimits::gm3508()).unwrap();
let score = objective(scenario).score(&trace); let score = objective(scenario).score(&trace);
assert_eq!(score.verdict, Verdict::NoSettle); 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] #[test]
@@ -2841,8 +2869,8 @@ pub mod search {
//! //!
//! # 为什么是两阶段 //! # 为什么是两阶段
//! //!
//! 成本函数有一大片"罚值平原"(不收敛 / 饱和的候选成本都等于 `PENALTY`), //! 失败候选位于 `PENALTY` 以上的罚值区带,并携带连续的指标残差;纯局部搜索
//! 纯局部搜索会陷在平原上;而纯网格搜索在四个数量级跨度上需要天文数字的 //! 仍难跨过成功 / 失败边界,而纯网格搜索在四个数量级跨度上需要天文数字的
//! 评估次数。两阶段各取所长: //! 评估次数。两阶段各取所长:
//! //!
//! 1. **粗扫**:Latin Hypercube 分层采样 + `rayon` 并行评估,用有限次数把 //! 1. **粗扫**:Latin Hypercube 分层采样 + `rayon` 并行评估,用有限次数把
@@ -3133,12 +3161,12 @@ pub mod search {
fn cost(&self, param: &Self::Param) -> Result<f64, ArgminError> { fn cost(&self, param: &Self::Param) -> Result<f64, ArgminError> {
let coords: [f64; AXIS_COUNT] = match param.as_slice() { let coords: [f64; AXIS_COUNT] = match param.as_slice() {
[a, b, c, d] => [*a, *b, *c, *d], [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 gains = self.space.from_coords(coords);
let cost = match self.budget.take() { let cost = match self.budget.take() {
Some(_) => self.evaluator.evaluate(&gains).cost, Some(_) => self.evaluator.evaluate(&gains).cost,
None => crate::cost::PENALTY, None => crate::cost::PENALTY * 2.0,
}; };
if cost.is_finite() { if cost.is_finite() {
self.tracker.record(cost, coords); self.tracker.record(cost, coords);
+26
View File
@@ -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`:四轮完整配置。
+4 -3
View File
@@ -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 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="": 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 rustup target add armv7-unknown-linux-musleabihf
scp -q target/release/chassis-tune {{board}}:/tmp/chassis-tune 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 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}}"