#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] //! PID tuning algorithms, search, scoring, and presets. /// Evaluation result shared by search backends. pub type Evaluated = crate::cost::Score; /// Scores one candidate gain set. pub trait Evaluator: Send + Sync { /// Evaluate a gain set. fn evaluate(&self, gains: &motor_control::VelocityGains) -> Evaluated; /// Describe the experiment for reports. fn describe(&self) -> String; } pub mod relay { //! 继电反馈(relay feedback)整定:自动找出临界增益 `Ku` 与临界周期 `Tu` //! (ARCHITECTURE.md §5.5)。 //! //! # 原理 //! //! 把控制器换成**继电环节**(输出在 `±amplitude` 之间跳变,带一点滞环抑制 //! 噪声误触发)。闭环在相频特性 −180° 的频率附近形成极限环: //! //! - 振荡**周期**即临界周期 `Tu`; //! - 振荡**幅值** `a` 与继电幅值 `d` 通过描述函数联系:`Ku = 4d / (π·a)`。 //! //! 拿到 `(Ku, Tu)` 后交给 [`super::zn`] 换算成 PID 增益。 //! //! # 什么时候这个实验有意义(重要) //! //! 描述函数法成立的前提是**线性部分对高次谐波有足够衰减**,也就是相频要真正 //! 穿过 −180°: //! //! - **有延迟 / 三阶以上**的被控对象(真实电调 + 机械、FOPDT):穿越点唯一, //! `Tu` 就是物理上的临界周期 ✅ //! - **纯二阶**对象:相频只**渐近**趋于 −180°,没有有限穿越点,极限环由 //! 滞环宽度决定(幅值 ≈ 滞环量级、周期远小于 `2π/wn`),此时算出的 `Ku` //! 偏大 ⚠️ //! - **纯一阶**对象:同样会振荡(由滞环决定),得到的是"滞环极限环"而非 //! 相频信息 ⚠️ //! //! 结论:把结果当作**起点**而不是终点——台架上用它快速拿到一组可用增益, //! 再用 `sim-harness` 的搜索式整定 / 阶跃复验确认。这也是 ARCHITECTURE.md §8.1 //! 的整定流程(`auto_tune` → 阶跃验证 → 上车微调)的用意。 //! //! # 用法(与 ARCHITECTURE.md §5.5 / §8.3 的流程一致) //! //! ```ignore //! let mut tuner = RelayTuner::new(amplitude, hysteresis); //! let mut t = 0.0; //! while t < max_time { //! let measured = plant.step(tuner.last_output(), dt)?; //! let output = tuner.step(measured, dt); //! t += dt; //! } //! let result = tuner.result(); // None = 没形成稳定振荡 //! ``` use crate::zn::{TuneResult, ZieglerNicholsRule}; /// 继电环节的当前状态。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RelayState { /// 输出 `+amplitude`。 High, /// 输出 `-amplitude`。 Low, } impl RelayState { /// 取反。 #[must_use] pub const fn flipped(self) -> Self { match self { Self::High => Self::Low, Self::Low => Self::High, } } /// 文本名。 #[must_use] pub const fn name(self) -> &'static str { match self { Self::High => "high", Self::Low => "low", } } } /// 一个振荡半周期组合(峰 → 谷 → 峰)的观测。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct OscillationPoint { /// 观测到的峰值。 pub peak: f32, /// 观测到的谷值。 pub trough: f32, /// 该次振荡的周期(秒)。 pub period_s: f32, } impl OscillationPoint { /// 振荡幅值 `a = (peak − trough) / 2`。 #[must_use] pub fn amplitude(&self) -> f32 { (self.peak - self.trough) / 2.0 } /// 振荡中心(应是工作点;偏离说明有静差 / 漂移)。 #[must_use] pub fn midpoint(&self) -> f32 { self.peak.midpoint(self.trough) } } /// 继电反馈整定器。 #[derive(Debug, Clone)] pub struct RelayTuner { /// 继电输出幅值 `d`(控制量单位)。 amplitude: f32, /// 滞环半宽(测量量单位):超过它才换向。 hysteresis: f32, /// 当前状态。 state: RelayState, /// 当前输出。 output: f32, /// 实验已进行的时间(秒)。 elapsed_s: f32, /// 上一次换向的时刻(秒)。 last_switch_s: f32, /// 上一次半周期长度(秒)。 last_half_s: Option, /// 本相内观测到的极值。 extreme: f32, /// 最近一次 High 相的峰值。 peak: Option, /// 最近一次 Low 相的谷值。 trough: Option, /// 已记录的振荡点。 points: Vec, /// 用于 `result()` 的规则。 rule: ZieglerNicholsRule, /// 参与平均的最近点数。 window: usize, } impl RelayTuner { /// 构造。`amplitude` 必须为正;`hysteresis` 取绝对值。 /// /// `amplitude` 的选择:太小会被摩擦 / 量化淹没(台架空载建议 ≥ 静摩擦 /// 量级的一倍),太大会让机械承受不必要的冲击。 #[must_use] pub fn new(amplitude: f32, hysteresis: f32) -> Self { let amplitude = if amplitude.is_finite() && amplitude > 0.0 { amplitude } else { 1.0 }; Self { amplitude, hysteresis: if hysteresis.is_finite() { hysteresis.abs() } else { 0.0 }, state: RelayState::High, output: amplitude, elapsed_s: 0.0, last_switch_s: 0.0, last_half_s: None, extreme: f32::NEG_INFINITY, peak: None, trough: None, points: Vec::new(), rule: ZieglerNicholsRule::default(), window: 4, } } /// 换一条 ZN 规则(链式)。 #[must_use] pub fn with_rule(mut self, rule: ZieglerNicholsRule) -> Self { self.rule = rule; self } /// 换平均窗口(参与 `Ku` / `Tu` 平均的最近点数,至少 1)。 #[must_use] pub fn with_window(mut self, window: usize) -> Self { self.window = window.max(1); self } /// 当前继电输出(喂给被控对象)。 #[must_use] pub fn last_output(&self) -> f32 { self.output } /// 当前状态。 #[must_use] pub fn state(&self) -> RelayState { self.state } /// 已记录的振荡点。 #[must_use] pub fn points(&self) -> &[OscillationPoint] { &self.points } /// 复位(做下一次实验前调用)。 pub fn reset(&mut self) { self.state = RelayState::High; self.output = self.amplitude; self.elapsed_s = 0.0; self.last_switch_s = 0.0; self.last_half_s = None; self.extreme = f32::NEG_INFINITY; self.peak = None; self.trough = None; self.points.clear(); } /// 推进一步:喂入测量值,取回继电输出。 /// /// 非有限测量值或非正 `dt` 时不推进状态(防御跳帧)。 pub fn step(&mut self, measured: f32, dt: f32) -> f32 { // 显式写 NaN 情形:`dt <= 0.0` 对 NaN 为假,会漏掉"跳帧 + NaN"的组合。 if !dt.is_finite() || dt <= 0.0 || !measured.is_finite() { return self.output; } self.elapsed_s += dt; // 本相内的极值 self.extreme = match self.state { RelayState::High => self.extreme.max(measured), RelayState::Low => self.extreme.min(measured), }; let switch = match self.state { // 输出为正 → 测量值上升;越过滞环上沿说明该换向 RelayState::High => measured >= self.hysteresis, RelayState::Low => measured <= -self.hysteresis, }; if switch { let half_s = self.elapsed_s - self.last_switch_s; self.last_switch_s = self.elapsed_s; match self.state { RelayState::High => self.peak = Some(self.extreme), RelayState::Low => self.trough = Some(self.extreme), } // 一个完整周期 = 相邻两个半周期;峰谷都有才能算幅值。 if let (Some(peak), Some(trough), Some(previous_half)) = (self.peak, self.trough, self.last_half_s) && peak > trough && half_s > 0.0 { self.points.push(OscillationPoint { peak, trough, period_s: previous_half + half_s, }); } self.last_half_s = Some(half_s); self.state = self.state.flipped(); self.output = match self.state { RelayState::High => self.amplitude, RelayState::Low => -self.amplitude, }; self.extreme = measured; } self.output } /// 平均后的 `(Ku, Tu)`:`None` 表示还没形成足够多的振荡。 #[must_use] pub fn ultimate(&self) -> Option<(f32, f32)> { if self.points.len() < 2 { return None; } let window = &self.points[self.points.len().saturating_sub(self.window)..]; let count = window.len() as f32; let mean_amplitude = window.iter().map(OscillationPoint::amplitude).sum::() / count; let mean_period = window.iter().map(|point| point.period_s).sum::() / count; let amplitude_ok = mean_amplitude.is_finite() && mean_amplitude > 0.0; let period_ok = mean_period.is_finite() && mean_period > 0.0; if !amplitude_ok || !period_ok { return None; } // 描述函数:Ku = 4d / (π·a) let ku = 4.0 * self.amplitude / (core::f32::consts::PI * mean_amplitude); if ku.is_finite() && ku > 0.0 { Some((ku, mean_period)) } else { None } } /// 整定结果(含 ZN 换算);`None` = 实验没有形成可用振荡。 #[must_use] pub fn result(&self) -> Option { let (ku, tu) = self.ultimate()?; TuneResult::from_ultimate(ku, tu, self.rule) } } #[cfg(test)] mod tests { use super::*; use motor_control::{ControlPlant, SecondOrderPlant}; /// 二阶对象在 `ω = wn` 处相移恰为 −180°,因此 `Tu ≈ 2π / wn`。 fn run_relay(wn: f32, zeta: f32, seconds: f32) -> RelayTuner { let dt = 0.001f32; let mut plant = SecondOrderPlant::new(wn, zeta, 1.0); let mut tuner = RelayTuner::new(1.0, 0.005); let steps = (seconds / dt) as usize; for _ in 0..steps { let measured = plant.step(tuner.last_output(), dt).unwrap(); tuner.step(measured, dt); } tuner } #[test] fn relay_reaches_a_limit_cycle_on_a_second_order_plant() { // ARCHITECTURE.md §5.5 的验收标准:继电整定在标准二阶系统上能收敛。 // 注意收敛的是"形成稳定极限环并给出有限 (Ku, Tu)",而不是 Tu = 2π/wn // (纯二阶没有有限的 −180° 穿越点,见模块文档)。 let tuner = run_relay(20.0, 0.3, 4.0); assert!(tuner.points().len() >= 3, "点数 = {}", tuner.points().len()); let (ku, tu) = tuner.ultimate().unwrap(); assert!(ku.is_finite() && ku > 0.0, "Ku = {ku}"); assert!(tu.is_finite() && tu > 0.0, "Tu = {tu}"); let result = tuner.result().unwrap(); assert!(result.gains.is_finite()); assert!(result.gains.kp > 0.0 && result.gains.ki > 0.0 && result.gains.kd > 0.0); } #[test] fn limit_cycle_is_repeatable_across_the_averaging_window() { // "收敛"的可检验含义:进入极限环后,逐周期的幅值与周期不再漂移。 let tuner = run_relay(20.0, 0.3, 4.0); let points = tuner.points(); assert!(points.len() >= 4, "点数 = {}", points.len()); let tail = &points[points.len() - 3..]; let first = tail[0]; for point in tail { let period_drift = (point.period_s - first.period_s).abs() / first.period_s; let amplitude_drift = (point.amplitude() - first.amplitude()).abs() / first.amplitude(); assert!(period_drift < 0.1, "周期漂移 {period_drift}:{point:?}"); assert!(amplitude_drift < 0.1, "幅值漂移 {amplitude_drift}:{point:?}"); } } #[test] fn amplitude_and_midpoint_stay_physical() { let tuner = run_relay(20.0, 0.3, 4.0); for point in tuner.points() { assert!(point.amplitude() > 0.0, "{point:?}"); assert!(point.period_s > 0.0, "{point:?}"); assert!(point.midpoint().abs() < 1.0, "中心应停在 0 附近:{point:?}"); } } #[test] fn no_limit_cycle_means_no_result() { // 没有实验 → 没有结果(而不是给一组乱数)。 let tuner = RelayTuner::new(1.0, 0.01); assert!(tuner.ultimate().is_none()); assert!(tuner.result().is_none()); // 测量值卡死在正侧(如机械堵转 / 传感器饱和):只换向一次,形不成振荡。 let mut tuner = RelayTuner::new(1.0, 0.1); for _ in 0..500 { tuner.step(5.0, 0.001); } assert!(tuner.points().len() < 2, "堵转测量不应产生振荡点:{:?}", tuner.points()); assert!(tuner.result().is_none(), "没有振荡就必须报失败"); } #[test] fn switching_respects_hysteresis_and_alternates() { let mut tuner = RelayTuner::new(2.0, 0.5); assert_eq!(tuner.state(), RelayState::High); assert!((tuner.last_output() - 2.0).abs() < 1e-6); // 未越过滞环:状态不变 assert!((tuner.step(0.4, 0.001) - 2.0).abs() < 1e-6); assert_eq!(tuner.state(), RelayState::High); // 越过上沿:翻到 Low assert!((tuner.step(0.6, 0.001) + 2.0).abs() < 1e-6); assert_eq!(tuner.state(), RelayState::Low); // 越过下沿:翻回 High assert!((tuner.step(-0.6, 0.001) - 2.0).abs() < 1e-6); assert_eq!(tuner.state(), RelayState::High); } #[test] fn bad_inputs_do_not_advance_state() { let mut tuner = RelayTuner::new(1.0, 0.1); let before = tuner.last_output(); assert!((tuner.step(f32::NAN, 0.001) - before).abs() < 1e-6); assert!((tuner.step(1.0, 0.0) - before).abs() < 1e-6); assert!((tuner.step(1.0, -0.001) - before).abs() < 1e-6); assert!(tuner.points().is_empty()); } #[test] fn reset_clears_the_experiment() { let mut tuner = run_relay(20.0, 0.3, 4.0); assert!(!tuner.points().is_empty()); tuner.reset(); assert!(tuner.points().is_empty()); assert_eq!(tuner.state(), RelayState::High); assert!(tuner.ultimate().is_none()); } #[test] fn degenerate_constructor_arguments_are_clamped() { let tuner = RelayTuner::new(0.0, f32::NAN); assert!((tuner.last_output() - 1.0).abs() < 1e-6, "非正幅值必须回落到安全默认"); let tuner = RelayTuner::new(-5.0, -1.0); assert!(tuner.last_output() > 0.0); assert_eq!(tuner.with_window(0).window, 1); } } } pub mod zn { //! Ziegler-Nichols 整定公式(ARCHITECTURE.md §5.5)。 //! //! 输入是**临界增益** `Ku` 与**临界周期** `Tu`(由继电反馈实验测得,见 //! [`super::relay`]),输出是一组 PID 增益: //! //! ```text //! Ti = α · Tu Td = β · Tu //! Kp = γ · Ku Ki = Kp / Ti Kd = Kp · Td //! ``` //! //! 单位约定:`Ku` 与 `Kp` 是「控制量 / 测量量」,`Ki` 是「控制量 / (测量量·s)」, //! `Kd` 是「控制量 / (测量量/s)」。速度环里测量量是 rpm、控制量是 raw, //! 于是正好得到 [`motor_control::VelocityGains`] 的单位。 //! //! # 选哪条规则 //! //! ZN 的经典整定**以超调大著称**(约 25%),对底盘速度环偏激进; //! [`ZieglerNicholsRule::NoOvershoot`] 更保守,是实车起步的稳妥选择。 use motor_control::VelocityGains; /// ZN 整定规则(系数来自 Ziegler-Nichols 1942 及后续的保守变体)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ZieglerNicholsRule { /// 经典 PID:`Kp = 0.6·Ku`、`Ti = 0.5·Tu`、`Td = 0.125·Tu`(超调约 25%)。 #[default] Classic, /// 少量超调:`Kp = 0.33·Ku`、`Ti = 0.5·Tu`、`Td = 0.33·Tu`。 SomeOvershoot, /// 无超调:`Kp = 0.2·Ku`、`Ti = 0.5·Tu`、`Td = 0.33·Tu`。 NoOvershoot, /// Pessen:`Kp = 0.7·Ku`、`Ti = 0.4·Tu`、`Td = 0.15·Tu`(更激进)。 Pessen, } impl ZieglerNicholsRule { /// 规则系数 `(γ = Kp/Ku, α = Ti/Tu, β = Td/Tu)`。 #[must_use] pub const fn coefficients(self) -> (f32, f32, f32) { match self { Self::Classic => (0.6, 0.5, 0.125), Self::SomeOvershoot => (0.33, 0.5, 0.33), Self::NoOvershoot => (0.2, 0.5, 0.33), Self::Pessen => (0.7, 0.4, 0.15), } } /// 文本名(报告 / CLI 用)。 #[must_use] pub const fn name(self) -> &'static str { match self { Self::Classic => "classic", Self::SomeOvershoot => "some-overshoot", Self::NoOvershoot => "no-overshoot", Self::Pessen => "pessen", } } } /// 由 `(Ku, Tu)` 与规则算出增益组。 /// /// # Errors /// /// `Ku` / `Tu` 非有限或非正时返回 `None`(宁可报"整定失败",也不要发散的增益)。 #[must_use] pub fn gains(ku: f32, tu: f32, rule: ZieglerNicholsRule) -> Option { if !(ku.is_finite() && tu.is_finite() && ku > 0.0 && tu > 0.0) { return None; } let (gamma, alpha, beta) = rule.coefficients(); let kp = gamma * ku; let ti = alpha * tu; let td = beta * tu; // ti 由上式必然为正有限;仍做一次防御,避免 NaN 传染给 PID(Pid::new 会 panic)。 if !(kp.is_finite() && ti.is_finite() && ti > 0.0 && td.is_finite()) { return None; } let gains = VelocityGains::new(kp, kp / ti, kp * td, 0.0); if gains.is_finite() { Some(gains) } else { None } } /// 一次整定的结果:中间量 + 换算后的增益(便于写进报告复现)。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct TuneResult { /// 临界增益。 pub ku: f32, /// 临界周期(秒)。 pub tu: f32, /// 使用的规则。 pub rule: ZieglerNicholsRule, /// 换算后的速度环增益(前馈为 0:ZN 不产生前馈项)。 pub gains: VelocityGains, } impl TuneResult { /// 按规则算出一组结果。 #[must_use] pub fn from_ultimate(ku: f32, tu: f32, rule: ZieglerNicholsRule) -> Option { let gains = gains(ku, tu, rule)?; Some(Self { ku, tu, rule, gains }) } /// 人类可读描述。 #[must_use] pub fn describe(&self) -> String { format!( "ZN({}):Ku={:.4} Tu={:.4}s → kp={:.4} ki={:.4} kd={:.4}", self.rule.name(), self.ku, self.tu, self.gains.kp, self.gains.ki, self.gains.kd ) } } #[cfg(test)] mod tests { use super::*; #[test] fn classic_formulas_match_the_textbook() { // Ku = 2, Tu = 0.5 → Kp = 1.2, Ti = 0.25, Td = 0.0625 // Ki = Kp / Ti = 4.8, Kd = Kp · Td = 0.075 let gains = gains(2.0, 0.5, ZieglerNicholsRule::Classic).unwrap(); assert!((gains.kp - 1.2).abs() < 1e-6); assert!((gains.ki - 4.8).abs() < 1e-6); assert!((gains.kd - 0.075).abs() < 1e-6); assert!(gains.feedforward.abs() < 1e-6, "ZN 不产生前馈"); } #[test] fn conservative_rules_give_smaller_kp() { let classic = gains(2.0, 0.5, ZieglerNicholsRule::Classic).unwrap(); let some = gains(2.0, 0.5, ZieglerNicholsRule::SomeOvershoot).unwrap(); let none = gains(2.0, 0.5, ZieglerNicholsRule::NoOvershoot).unwrap(); let pessen = gains(2.0, 0.5, ZieglerNicholsRule::Pessen).unwrap(); assert!(pessen.kp > classic.kp); assert!(classic.kp > some.kp); assert!(some.kp > none.kp); } #[test] fn degenerate_ultimate_values_are_rejected() { for (ku, tu) in [(0.0, 1.0), (1.0, 0.0), (-1.0, 1.0), (1.0, -1.0), (f32::NAN, 1.0)] { assert!(gains(ku, tu, ZieglerNicholsRule::Classic).is_none(), "ku={ku} tu={tu}"); } } #[test] fn result_carries_the_intermediate_values() { let result = TuneResult::from_ultimate(2.0, 0.5, ZieglerNicholsRule::NoOvershoot).unwrap(); assert!((result.ku - 2.0).abs() < 1e-6); assert!((result.tu - 0.5).abs() < 1e-6); assert_eq!(result.rule, ZieglerNicholsRule::NoOvershoot); let text = result.describe(); assert!(text.contains("no-overshoot"), "{text}"); assert!(text.contains("Ku=2.0000"), "{text}"); } #[test] fn rule_names_are_stable() { for (rule, name) in [ (ZieglerNicholsRule::Classic, "classic"), (ZieglerNicholsRule::SomeOvershoot, "some-overshoot"), (ZieglerNicholsRule::NoOvershoot, "no-overshoot"), (ZieglerNicholsRule::Pessen, "pessen"), ] { assert_eq!(rule.name(), name); } assert_eq!(ZieglerNicholsRule::default(), ZieglerNicholsRule::Classic); } } } pub mod cost { //! 成本函数:把指标折成一个标量,并把"不可接受"变成硬约束。 //! //! 两个原则: //! //! 1. **硬约束不用权重表达**。过速 / 长时间饱和 / 不收敛不是"差一点", //! 而是"不能用"——它们直接给罚值并记录判定,绝不允许被其他项的权重 //! 抵消掉(否则搜索会主动挑一个会飞车的解)。 //! 2. **各项归一化**。时间按轨迹时长归一、误差按 `|目标| × 时长` 归一、 //! effort 按限幅归一,因此权重在不同场景 / 不同电机之间仍然可解释。 use experiment::{Scenario, Trace}; use sim_harness::{LoopLimits, SimError}; use experiment::StepMetrics; /// 罚值:远大于任何合法成本,但保持有限(避免 NaN 传染给优化器)。 pub const PENALTY: f64 = 1.0e9; /// 成本权重(各项已归一化到 O(1))。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct Weights { /// 调节时间权重。 pub settle: f64, /// 超调权重。 pub overshoot: f64, /// 绝对误差积分(IAE)权重。 pub iae: f64, /// effort(电流)权重。 pub effort: f64, } impl Default for Weights { fn default() -> Self { // 底盘速度环:跟得上、不过冲最重要;电流只作为次要惩罚 // (省电流不是目的,避免烧电调才是)。 Self { settle: 1.0, overshoot: 1.0, iae: 0.5, effort: 0.1 } } } impl Weights { /// 人类可读描述(日志 / 报告用)。 #[must_use] pub fn describe(&self) -> String { format!( "cost={:.2}·settle+{:.2}·overshoot+{:.2}·iae+{:.2}·effort", self.settle, self.overshoot, self.iae, self.effort ) } /// 权重是否全部有限且非负。 #[must_use] pub fn is_valid(&self) -> bool { [self.settle, self.overshoot, self.iae, self.effort] .iter() .all(|w| w.is_finite() && *w >= 0.0) } } /// 一次评估的判定。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Verdict { /// 达标(成本 = 加权指标)。 Ok, /// 增益含 NaN / inf,或场景退化(被拒绝,不进控制器)。 Rejected, /// 数值发散。 Diverged, /// 过速。 Overspeed, /// 饱和占比过高(积分绕死 / 限幅太紧)。 Saturated, /// 在场景内没有收敛。 NoSettle, } impl Verdict { /// 是否达标。 #[must_use] pub fn is_ok(self) -> bool { matches!(self, Self::Ok) } /// 简短标签(报告 / CSV 用)。 #[must_use] pub fn label(self) -> &'static str { match self { Self::Ok => "ok", Self::Rejected => "rejected", Self::Diverged => "diverged", Self::Overspeed => "overspeed", Self::Saturated => "saturated", Self::NoSettle => "no-settle", } } } /// 一次评估的结果。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct Score { /// 成本(越小越好)。 pub cost: f64, /// 判定。 pub verdict: Verdict, /// 指标(被拒绝时为 `None`)。 pub metrics: Option, } impl Score { /// 罚分结果。 #[must_use] pub fn penalized(verdict: Verdict) -> Self { Self { cost: PENALTY, verdict, metrics: None } } } /// 目标函数:场景 + 限幅 + 权重 + 硬约束。 #[derive(Debug, Clone, PartialEq)] pub struct Objective { /// 权重。 pub weights: Weights, /// 评价场景。 pub scenario: Scenario, /// 限幅与型号。 pub limits: LoopLimits, /// 过速硬上限(rpm)。 pub max_rpm: f32, /// 允许的饱和占比上限(0..1)。 pub max_saturated_fraction: f32, } impl Objective { /// 默认硬约束:过速上限 = 目标 × 3 + 500;饱和占比 ≤ 40%。 #[must_use] pub fn new(weights: Weights, scenario: Scenario, limits: LoopLimits) -> Self { let max_rpm = scenario.target_rpm.abs() * 3.0 + 500.0; Self { weights, scenario, limits, max_rpm, max_saturated_fraction: 0.4 } } /// 由仿真错误折算判定。 #[must_use] pub fn from_error(error: SimError) -> Verdict { match error { SimError::NonFiniteGains | SimError::DegenerateScenario => Verdict::Rejected, SimError::Diverged { .. } => Verdict::Diverged, } } /// 对一条轨迹评分。 #[must_use] pub fn score(&self, trace: &Trace) -> Score { let Some(metrics) = StepMetrics::from_trace(trace, &self.scenario) else { return Score::penalized(Verdict::Rejected); }; self.score_metrics(metrics) } /// 对一组指标评分(真机后端直接复用,保证两条路径同一把尺子)。 #[must_use] pub fn score_metrics(&self, metrics: StepMetrics) -> Score { if metrics.max_rpm > self.max_rpm { return Score { cost: PENALTY, verdict: Verdict::Overspeed, metrics: Some(metrics), }; } if metrics.saturated_pct / 100.0 > self.max_saturated_fraction { return Score { cost: PENALTY, verdict: Verdict::Saturated, metrics: Some(metrics), }; } if !metrics.settled { return Score { cost: PENALTY, verdict: Verdict::NoSettle, metrics: Some(metrics) }; } Score { cost: self.weighted_cost(&metrics), verdict: Verdict::Ok, metrics: Some(metrics), } } /// 归一化加权成本。 #[must_use] pub fn weighted_cost(&self, metrics: &StepMetrics) -> f64 { let total_s = f64::from(self.scenario.total_s().max(1.0e-3)); let target = f64::from(self.scenario.target_rpm.abs()).max(1.0e-3); let limit = f64::from(self.limits.current_limit_raw.max(1)); let settle = f64::from(metrics.settling_time_s) / total_s; let overshoot = f64::from(metrics.overshoot_pct) / 100.0; let iae = f64::from(metrics.iae) / (target * total_s); let effort = f64::from(metrics.rms_effort_raw) / limit; self.weights.settle * settle + self.weights.overshoot * overshoot + self.weights.iae * iae + self.weights.effort * effort } /// 稳态误差是否满足验收阈值(报告 / 退出码用)。 #[must_use] pub fn passes( &self, metrics: &StepMetrics, max_overshoot_pct: f32, max_ss_error_pct: f32, ) -> bool { metrics.settled && metrics.overshoot_pct <= max_overshoot_pct && metrics.ss_error_pct <= max_ss_error_pct } } #[cfg(test)] mod tests { use super::*; use motor_control::VelocityGains; use motor_model::PlantParams; use sim_harness::simulate; const GOOD: VelocityGains = VelocityGains::new(8.0, 2.0, 0.0, 16384.0 / 9254.0); fn objective(scenario: Scenario) -> Objective { Objective::new(Weights::default(), scenario, LoopLimits::gm3508()) } #[test] fn good_gains_produce_a_finite_ok_cost() { let scenario = Scenario::step(1500.0, 1.0); let trace = simulate(GOOD, &PlantParams::gm3508(), &scenario, LoopLimits::gm3508()).unwrap(); let score = objective(scenario).score(&trace); assert_eq!(score.verdict, Verdict::Ok); assert!(score.cost.is_finite() && score.cost < 10.0, "cost = {}", score.cost); assert!(score.metrics.is_some()); } #[test] fn non_converging_gains_are_penalized_not_weighted() { // 纯比例、极低增益:到不了目标。 let scenario = Scenario::step(4000.0, 1.0); let weak = VelocityGains::new(0.01, 0.0, 0.0, 0.0); let trace = 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); } #[test] fn saturation_is_a_hard_constraint() { let scenario = Scenario::step(9000.0, 1.0); // 增益极大 + 限流很小 → 长时间饱和。 let limits = LoopLimits { current_limit_raw: 800, model: motor_protocol::EscModel::C620 }; let trace = simulate( VelocityGains::new(500.0, 0.0, 0.0, 0.0), &PlantParams::gm3508(), &scenario, limits, ) .unwrap(); let score = Objective::new(Weights::default(), scenario, limits).score(&trace); assert!( matches!(score.verdict, Verdict::Saturated | Verdict::NoSettle), "{:?}", score.verdict ); assert!(score.cost >= PENALTY); } #[test] fn verdict_labels_are_stable() { assert_eq!(Verdict::Ok.label(), "ok"); assert_eq!(Verdict::NoSettle.label(), "no-settle"); assert!(Verdict::Ok.is_ok()); assert!(!Verdict::Overspeed.is_ok()); } #[test] fn weights_must_be_finite_and_non_negative() { assert!(Weights::default().is_valid()); let bad = Weights { settle: -1.0, ..Weights::default() }; assert!(!bad.is_valid()); let nan = Weights { iae: f64::NAN, ..Weights::default() }; assert!(!nan.is_valid()); } #[test] fn weighted_cost_decreases_when_the_response_improves() { let scenario = Scenario::step(1500.0, 1.0); let objective = objective(scenario); let good = simulate(GOOD, &PlantParams::gm3508(), &scenario, LoopLimits::gm3508()).unwrap(); let sluggish = simulate( VelocityGains::new(0.6, 1.0, 0.0, 0.0), &PlantParams::gm3508(), &scenario, LoopLimits::gm3508(), ) .unwrap(); let good_metrics = StepMetrics::from_trace(&good, &scenario).unwrap(); let sluggish_metrics = StepMetrics::from_trace(&sluggish, &scenario).unwrap(); assert!( objective.weighted_cost(&good_metrics) < objective.weighted_cost(&sluggish_metrics), "好的响应必须成本更低" ); } #[test] fn sim_errors_map_to_verdicts() { assert_eq!(Objective::from_error(SimError::NonFiniteGains), Verdict::Rejected); assert_eq!(Objective::from_error(SimError::DegenerateScenario), Verdict::Rejected); assert_eq!(Objective::from_error(SimError::Diverged { step: 3 }), Verdict::Diverged); } } } pub mod gains { //! 增益参数空间:轴定义、采样与**坐标空间**映射。 //! //! # 为什么要有"坐标空间" //! //! 增益的合法取值跨越数量级(`ki` 可能在 0.1 与 100 之间),线性空间的 //! 单纯形步长对数量级不敏感;同时 `kp/ki/kd` 必须为正。把每个轴映射到 //! **坐标空间**(对数轴取 `ln`,线性轴恒等)后: //! //! - 无约束优化器不会走出正增益区域; //! - 步长在不同数量级上等价(±0.1 的坐标步长 = ×1.1 的比例步长); //! - `LogOrZero` 轴在**粗扫**阶段可以精确取 0(`ff = 0` 是台架常态), //! 在**精修**阶段退化为对数轴(零是不可导的拐点,本地搜索收益为负)。 use motor_control::VelocityGains; use rand::Rng; use rand::seq::SliceRandom as _; use thiserror::Error; /// 轴数量:`kp`、`ki`、`kd`、`feedforward`。 pub const AXIS_COUNT: usize = 4; /// 轴的取值范围映射方式。 #[derive(Debug, Clone, Copy, PartialEq)] pub enum Scale { /// 线性均匀。 Linear, /// 对数均匀(要求 `min > 0`)。 Log, /// 以概率 `p_zero` 取精确 0,否则对数均匀(用于 `ki` / `kd` / `ff`)。 LogOrZero { /// 取 0 的概率。 p_zero: f64, }, } impl Scale { /// 是否是对数族(`Log` / `LogOrZero`)。 #[must_use] pub fn is_log(self) -> bool { matches!(self, Self::Log | Self::LogOrZero { .. }) } /// 从文本解析(CLI 用)。 /// /// # Errors /// /// 未知名字时返回 [`GainsError::UnknownScale`]。 pub fn parse(text: &str) -> Result { match text { "linear" => Ok(Self::Linear), "log" => Ok(Self::Log), "log0" | "log-or-zero" => Ok(Self::LogOrZero { p_zero: 0.25 }), other => Err(GainsError::UnknownScale(other.to_owned())), } } /// 文本名。 #[must_use] pub fn name(self) -> &'static str { match self { Self::Linear => "linear", Self::Log => "log", Self::LogOrZero { .. } => "log0", } } } /// 参数空间定义错误。 #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum GainsError { /// 轴区间非法。 #[error("invalid range for {axis}: {reason}")] InvalidRange { /// 轴名。 axis: String, /// 原因。 reason: String, }, /// 未知的映射方式名。 #[error("unknown scale '{0}' (expected linear|log|log0)")] UnknownScale(String), /// 区间文本无法解析(期望 `MIN..MAX`)。 #[error("cannot parse range '{0}' (expected MIN..MAX)")] BadRange(String), /// 只给了对数轴 0 下界。 #[error("axis {0} uses a log scale but min must be > 0")] NonPositiveLogMin(String), } /// 单条增益轴。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct GainAxis { /// 轴名(`kp` / `ki` / `kd` / `feedforward`)。 pub name: &'static str, /// 下界。 pub min: f64, /// 上界。 pub max: f64, /// 取值映射。 pub scale: Scale, } impl GainAxis { /// 构造。 #[must_use] pub const fn new(name: &'static str, min: f64, max: f64, scale: Scale) -> Self { Self { name, min, max, scale } } /// 校验。 /// /// # Errors /// /// 非有限 / `min > max` / 对数轴下界 ≤ 0 时返回错误。 pub fn validate(&self) -> Result<(), GainsError> { let axis = self.name.to_owned(); if !(self.min.is_finite() && self.max.is_finite()) { return Err(GainsError::InvalidRange { axis, reason: "bounds must be finite".into(), }); } if self.min > self.max { return Err(GainsError::InvalidRange { axis, reason: format!("min {} > max {}", self.min, self.max), }); } if self.scale.is_log() && self.min <= 0.0 { return Err(GainsError::NonPositiveLogMin(axis)); } Ok(()) } /// 在区间内采样(含 `LogOrZero` 的零分支)。 pub fn sample(&self, rng: &mut impl Rng) -> f64 { match self.scale { Scale::Linear => rng.random_range(self.min..=self.max), Scale::Log => self.log_sample(rng), Scale::LogOrZero { p_zero } => { if rng.random_bool(p_zero.clamp(0.0, 1.0)) { 0.0 } else { self.log_sample(rng) } } } } fn log_sample(&self, rng: &mut impl Rng) -> f64 { let lo = self.min.max(f64::MIN_POSITIVE).ln(); let hi = self.max.ln(); if hi <= lo { return self.min; } rng.random_range(lo..=hi).exp() } /// 夹到区间内。 #[must_use] pub fn clamp(&self, value: f64) -> f64 { let clamped = value.clamp(self.min, self.max); if clamped.is_finite() { clamped } else { self.min } } /// 参数 → 坐标。 #[must_use] pub fn encode(&self, value: f64) -> f64 { if self.scale.is_log() { self.clamp(value).max(f64::MIN_POSITIVE).ln() } else { self.clamp(value) } } /// 坐标 → 参数(先夹到坐标边界,再取指数)。 #[must_use] pub fn decode(&self, coord: f64) -> f64 { let (lo, hi) = self.coord_bounds(); let coord = if coord.is_finite() { coord.clamp(lo, hi) } else { lo }; if self.scale.is_log() { coord.exp() } else { coord } } /// 坐标空间的边界。 #[must_use] pub fn coord_bounds(&self) -> (f64, f64) { if self.scale.is_log() { (self.min.max(f64::MIN_POSITIVE).ln(), self.max.ln()) } else { (self.min, self.max) } } /// 人类可读描述。 #[must_use] pub fn describe(&self) -> String { format!("{} ∈ [{}..{}] ({})", self.name, self.min, self.max, self.scale.name()) } } /// 四轴参数空间。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct GainSpace { /// 比例增益(raw / rpm)。 pub kp: GainAxis, /// 积分增益(raw / (rpm·s))。 pub ki: GainAxis, /// 微分增益。 pub kd: GainAxis, /// 速度前馈(raw / rpm)。 pub feedforward: GainAxis, } impl GainSpace { /// 组装。 #[must_use] pub const fn new(kp: GainAxis, ki: GainAxis, kd: GainAxis, feedforward: GainAxis) -> Self { Self { kp, ki, kd, feedforward } } /// 默认空间:覆盖空载台架(`kp≈2, ki≈10, ff=0`)与带载(`ff≈1.77`)两侧。 #[must_use] pub const fn default_gm3508() -> Self { Self { kp: GainAxis::new("kp", 0.5, 30.0, Scale::Log), ki: GainAxis::new("ki", 0.1, 100.0, Scale::LogOrZero { p_zero: 0.2 }), kd: GainAxis::new("kd", 0.01, 3.0, Scale::LogOrZero { p_zero: 0.5 }), feedforward: GainAxis::new( "feedforward", 0.01, 2.5, Scale::LogOrZero { p_zero: 0.2 }, ), } } /// 校验全部轴。 /// /// # Errors /// /// 任一轴非法时返回错误。 pub fn validate(&self) -> Result<(), GainsError> { for axis in self.axes() { axis.validate()?; } Ok(()) } /// 四轴(顺序固定 `kp, ki, kd, feedforward`)。 #[must_use] pub const fn axes(&self) -> [GainAxis; AXIS_COUNT] { [self.kp, self.ki, self.kd, self.feedforward] } /// 随机采样一个候选增益。 pub fn sample(&self, rng: &mut impl Rng) -> VelocityGains { let [kp, ki, kd, ff] = self.axes().map(|axis| axis.sample(rng)); VelocityGains::new(kp as f32, ki as f32, kd as f32, ff as f32) } /// 分层(Latin Hypercube)采样:每个轴把 `[0,1)` 等分成 `count` 层, /// 每层恰好落一个样本,再逐轴独立打乱。 /// /// 相比独立随机采样,分层采样在四维空间里不会留下大片空洞,粗扫阶段 /// 用更少的评估次数覆盖更大的空间。 pub fn stratified(&self, count: usize, rng: &mut impl Rng) -> Vec { let count = count.max(1); let mut columns: Vec> = Vec::with_capacity(AXIS_COUNT); for axis in self.axes() { let (lo, hi) = axis.coord_bounds(); let mut column = Vec::with_capacity(count); for layer in 0..count { let unit = (layer as f64 + rng.random_range(0.0..1.0)) / count as f64; column.push(lo + unit * (hi - lo)); } column.shuffle(rng); columns.push(column); } (0..count) .map(|index| { self.from_coords([ columns[0][index], columns[1][index], columns[2][index], columns[3][index], ]) }) .collect() } /// 把任意(可能越界 / 非有限)增益夹进空间。 #[must_use] pub fn clamp_gains(&self, gains: VelocityGains) -> VelocityGains { self.from_values([gains.kp, gains.ki, gains.kd, gains.feedforward].map(f64::from)) } /// 由四个参数值构造(先夹界,再保证有限)。 #[must_use] pub fn from_values(&self, values: [f64; AXIS_COUNT]) -> VelocityGains { let axes = self.axes(); let v: [f64; AXIS_COUNT] = std::array::from_fn(|i| axes[i].clamp(values[i])); VelocityGains::new(v[0] as f32, v[1] as f32, v[2] as f32, v[3] as f32) } /// 参数 → 坐标。 #[must_use] pub fn to_coords(&self, gains: &VelocityGains) -> [f64; AXIS_COUNT] { let axes = self.axes(); let values = [gains.kp, gains.ki, gains.kd, gains.feedforward].map(f64::from); std::array::from_fn(|i| axes[i].encode(values[i])) } /// 坐标 → 参数。 #[must_use] pub fn from_coords(&self, coords: [f64; AXIS_COUNT]) -> VelocityGains { let axes = self.axes(); let v: [f64; AXIS_COUNT] = std::array::from_fn(|i| axes[i].decode(coords[i])); VelocityGains::new(v[0] as f32, v[1] as f32, v[2] as f32, v[3] as f32) } /// 坐标空间的边界(下界数组, 上界数组)。 #[must_use] pub fn coord_bounds(&self) -> ([f64; AXIS_COUNT], [f64; AXIS_COUNT]) { let axes = self.axes(); let pairs: [(f64, f64); AXIS_COUNT] = std::array::from_fn(|i| axes[i].coord_bounds()); let lower = std::array::from_fn(|i| pairs[i].0); let upper = std::array::from_fn(|i| pairs[i].1); (lower, upper) } /// 最优解是否贴在某个轴的边界上(提示是否该放宽搜索范围)。 /// /// 贴边界通常意味着"约束而不是数据在决定结果"——继续在这个盒子里搜索 /// 不会更好,报告里要明确说出来,而不是把一个边界值当成最优解交付。 #[must_use] pub fn boundary_hits(&self, gains: &VelocityGains) -> Vec { let values = [gains.kp, gains.ki, gains.kd, gains.feedforward]; let mut hits = Vec::new(); for (axis, value) in self.axes().iter().zip(values) { let value = f64::from(value); let tolerance = ((axis.max - axis.min).abs() * 0.01).max(1.0e-9); if (value - axis.max).abs() <= tolerance { hits.push(format!( "{} 落在上界 {}(若要继续提升,放宽 --{})", axis.name, axis.max, axis.name )); } else if (value - axis.min).abs() <= tolerance { hits.push(format!( "{} 落在下界 {}(若要继续压缩,放宽 --{})", axis.name, axis.min, axis.name )); } } hits } /// 人类可读描述(报告用)。 #[must_use] pub fn describe(&self) -> String { self.axes().iter().map(GainAxis::describe).collect::>().join("; ") } } /// 解析 `MIN..MAX`(`..=` 视为等价)。 /// /// # Errors /// /// 无法解析或区间非法时返回错误。 pub fn parse_axis( name: &'static str, spec: &str, scale: Scale, ) -> Result { let spec = spec.trim(); let bad = || GainsError::BadRange(spec.to_owned()); // `MIN..MAX` 与 `MIN..=MAX` 视为等价;单个数值表示固定该轴。 let (min_text, max_text) = match spec.split_once("..=").or_else(|| spec.split_once("..")) { Some((lo, hi)) => (lo.trim(), hi.trim()), None => (spec, spec), }; if min_text.is_empty() || max_text.is_empty() { return Err(bad()); } let min: f64 = min_text.parse().map_err(|_| bad())?; let max: f64 = max_text.parse().map_err(|_| bad())?; let axis = GainAxis::new(name, min, max, scale); axis.validate()?; Ok(axis) } #[cfg(test)] mod tests { use super::*; use rand::SeedableRng as _; use rand::rngs::StdRng; fn rng(seed: u64) -> StdRng { StdRng::seed_from_u64(seed) } #[test] fn clamp_keeps_values_inside_the_space() { let space = GainSpace::default_gm3508(); let wild = VelocityGains::new(1.0e9, -5.0, 100.0, f32::NAN); let clamped = space.clamp_gains(wild); assert!(clamped.kp <= 30.0 && clamped.kp >= 0.5); assert!(clamped.ki >= 0.1); assert!(clamped.kd <= 3.0); assert!(clamped.feedforward.is_finite()); assert!(clamped.is_finite()); } #[test] fn sampling_stays_inside_bounds_and_is_deterministic() { let space = GainSpace::default_gm3508(); let mut a = rng(7); let mut b = rng(7); for _ in 0..500 { let g1 = space.sample(&mut a); let g2 = space.sample(&mut b); assert_eq!(g1, g2, "同 seed 必须一致"); assert!((0.5..=30.0).contains(&g1.kp)); assert!((0.0..=100.0).contains(&g1.ki)); assert!((0.0..=3.0).contains(&g1.kd)); assert!((0.0..=2.5).contains(&g1.feedforward)); assert!(g1.is_finite()); } } #[test] fn log_axis_samples_across_orders_of_magnitude() { let axis = GainAxis::new("kp", 0.1, 100.0, Scale::Log); let mut rng = rng(3); let mut below_one = 0; let mut above_ten = 0; for _ in 0..2000 { let value = axis.sample(&mut rng); assert!((0.1..=100.0).contains(&value)); if value < 1.0 { below_one += 1; } if value > 10.0 { above_ten += 1; } } // 对数均匀:三个数量级应大致各占 1/3。 assert!(below_one > 400 && below_one < 900, "below_one = {below_one}"); assert!(above_ten > 400 && above_ten < 900, "above_ten = {above_ten}"); } #[test] fn log_or_zero_hits_exact_zero() { let axis = GainAxis::new("feedforward", 0.01, 2.5, Scale::LogOrZero { p_zero: 0.5 }); let mut rng = rng(11); let mut zeros = 0; for _ in 0..2000 { if axis.sample(&mut rng) == 0.0 { zeros += 1; } } assert!((800..1200).contains(&zeros), "zeros = {zeros}"); } #[test] fn coord_round_trip_is_stable() { let space = GainSpace::default_gm3508(); let mut rng = rng(5); let mut saw_zero = false; for _ in 0..200 { let gains = space.sample(&mut rng); let back = space.from_coords(space.to_coords(&gains)); // 坐标空间对**非零**取值是双射(只允许 f32 舍入误差); // 0 在对数轴里没有原像,往返会落到该轴下界——这是 LogOrZero 的定义。 for (axis, (sampled, restored)) in space.axes().iter().zip([ (gains.kp, back.kp), (gains.ki, back.ki), (gains.kd, back.kd), (gains.feedforward, back.feedforward), ]) { if sampled == 0.0 { saw_zero = true; assert!( (restored - axis.min as f32).abs() <= 1e-6 * axis.min as f32, "{} 的零值往返应落到下界 {},得到 {restored}", axis.name, axis.min ); } else { let tolerance = 1e-3 * sampled.max(1.0); assert!( (restored - sampled).abs() <= tolerance, "{} 往返误差过大:{sampled} → {restored}", axis.name ); } } } assert!(saw_zero, "LogOrZero 轴必须真的采到过 0"); } #[test] fn decode_clamps_out_of_range_coordinates() { let space = GainSpace::default_gm3508(); let below = space.from_coords([-1.0e9, -1.0e9, -1.0e9, -1.0e9]); assert!(below.kp >= 0.5); let above = space.from_coords([1.0e9; AXIS_COUNT]); assert!(above.kp <= 30.0); assert!(above.feedforward.is_finite()); } #[test] fn stratified_covers_every_layer_of_every_axis() { let space = GainSpace::default_gm3508(); let count = 32; let samples = space.stratified(count, &mut rng(9)); assert_eq!(samples.len(), count); // 每个轴在坐标空间被等分:把样本坐标排序后,相邻差应大致等于层宽。 let (lower, upper) = space.coord_bounds(); let span = upper[0] - lower[0]; let mut coords: Vec = samples.iter().map(|g| space.to_coords(g)[0]).collect(); coords.sort_by(f64::total_cmp); let layer = span / count as f64; for window in coords.windows(2) { let gap = window[1] - window[0]; assert!(gap < layer * 3.0, "gap {gap} too large for layer {layer}"); } } #[test] fn stratified_handles_large_counts_without_truncation() { let space = GainSpace::default_gm3508(); let samples = space.stratified(96, &mut rng(4)); assert_eq!(samples.len(), 96); } #[test] fn parse_axis_accepts_ranges_and_single_values() { let axis = parse_axis("kp", "0.5..20", Scale::Log).unwrap(); assert!((axis.min - 0.5).abs() < 1e-12); assert!((axis.max - 20.0).abs() < 1e-12); let open = parse_axis("ki", "0..=50", Scale::Linear).unwrap(); assert!((open.max - 50.0).abs() < 1e-12); let fixed = parse_axis("kd", "0.25", Scale::Linear).unwrap(); assert!((fixed.min - 0.25).abs() < 1e-12 && (fixed.max - 0.25).abs() < 1e-12); } #[test] fn parse_axis_rejects_garbage() { assert!(matches!(parse_axis("kp", "abc", Scale::Log), Err(GainsError::BadRange(_)))); assert!(matches!(parse_axis("kp", "", Scale::Log), Err(GainsError::BadRange(_)))); assert!(matches!( parse_axis("kp", "-1..5", Scale::Log), Err(GainsError::NonPositiveLogMin(_)) )); } #[test] fn validate_rejects_inverted_and_non_finite_bounds() { let bad = GainAxis::new("kp", 10.0, 1.0, Scale::Linear); assert!(bad.validate().is_err()); let nan = GainAxis::new("kp", f64::NAN, 1.0, Scale::Linear); assert!(nan.validate().is_err()); } #[test] fn validate_accepts_the_default_space() { assert!(GainSpace::default_gm3508().validate().is_ok()); } #[test] fn boundary_hits_flag_pinned_axes_only() { let space = GainSpace::default_gm3508(); let pinned = VelocityGains::new(30.0, 0.1, 1.0, 1.0); let hits = space.boundary_hits(&pinned); assert_eq!(hits.len(), 2, "{hits:?}"); assert!(hits.iter().any(|hit| hit.contains("kp")), "{hits:?}"); assert!(hits.iter().any(|hit| hit.contains("ki")), "{hits:?}"); let inside = VelocityGains::new(5.0, 10.0, 0.5, 1.0); assert!(space.boundary_hits(&inside).is_empty()); } } } pub mod preset { //! 整定**超参数**预设:把"搜什么、怎么打分、什么算达标"从 CLI 默认值提升为 //! 版本化、可评审、可复现的 TOML 产物。 //! //! # 为什么需要它 //! //! 一次整定的结果不只取决于被控对象与种子,还取决于一组**超参数**: //! //! | 组 | 内容 | 影响 | //! | --- | --- | --- | //! | `space` | 四轴区间与刻度 | 能不能搜到解(解在区间外 = 白搜) | //! | `scenario` | 设定值曲线 / 目标转速 / 节拍 | 评的是"哪种工况下的好" | //! | `cost` | 调节时间 / 超调 / IAE / 电流权重 | 什么叫"好" | //! | `acceptance` | 超调与稳态误差阈值 | 什么算"通过"(退出码) | //! | `search` | 种子 / 样本数 / top-k / 求解器 / 预算 | 搜得多细、能否复现 | //! | `sim` | 被控对象与域随机化幅度 | 鲁棒优化对抗的是哪种不确定性 | //! | `bench` | 真机安全包络与试验节奏 | 台架会不会飞车、跑多久 | //! //! 把这些散落在命令行默认值里,等于"实验条件"没有进版本管理:换台机器、 //! 隔一周再跑,没人能说清当时那组数是怎么来的。本模块把它们变成 //! `config/tune.toml` 里的具名预设,一次评审、长期复用。 //! //! # 优先级 //! //! ```text //! 内置默认 < 预设文件里的预设 < 显式命令行参数 //! ``` //! //! 逐字段覆盖:预设只写它关心的项,其余沿用内置默认;命令行只写本次要变的项。 //! 因此"跑一次预设 + 临时改一个权重"是安全的,不会把其它超参数打回硬编码值。 //! //! # 不在这里的东西 //! //! `current_limit`、`direction`、`gear_ratio`、电调 ID、串口 / 接口名属于 //! **安全与机械约束**,由 `chassis.toml`(`chassis-config`)给出,不参与搜索, //! 也不允许被预设覆盖——否则预设文件就成了绕过限流的口子。 //! //! # 例 //! //! ```toml //! version = 1 //! default_preset = "sim_bench_model" //! //! [preset.bench_noload] //! description = "空载台架:摩擦小、静差靠积分压制" //! backend = "bench" //! //! [preset.bench_noload.space] //! kp = { min = 0.5, max = 30.0, scale = "log" } //! ki = { min = 0.1, max = 100.0, scale = "log0" } //! //! [preset.bench_noload.search] //! samples = 24 //! top_k = 3 //! ``` use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use experiment::{Scenario, SetpointProfile}; use motor_model::PlantParams; use serde::{Deserialize, Serialize}; use sim_harness::PlantTolerances; use thiserror::Error; use crate::cost::Weights; use crate::gains::{GainAxis, GainSpace, Scale}; use crate::search::{SearchConfig, SolverKind}; /// 预设 schema 版本。schema 不兼容变更时递增。 pub const PRESET_VERSION: u32 = 1; /// 未显式给 `--preset` 时尝试的默认预设路径。 pub const DEFAULT_PRESET_PATH: &str = "config/tune.toml"; /// `log0` 轴取精确 0 的概率(与 `Scale::parse` 保持一致)。 const LOG0_P_ZERO: f64 = 0.25; // ---------------------------------------------------------------- 枚举 /// 评估后端。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum BackendSpec { /// 离线被控对象仿真。 Sim, /// 真机台架(会驱动电机)。 Bench, } impl BackendSpec { /// 文本名。 #[must_use] pub const fn name(self) -> &'static str { match self { Self::Sim => "sim", Self::Bench => "bench", } } } /// 增益轴的取值映射。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ScaleSpec { /// 线性均匀。 Linear, /// 对数均匀(要求 `min > 0`)。 Log, /// 以固定概率 0.25 取精确 0,否则对数均匀(与 `Scale::parse` 的 `log0` 一致)。 Log0, } impl ScaleSpec { /// 文本名。 #[must_use] pub const fn name(self) -> &'static str { match self { Self::Linear => "linear", Self::Log => "log", Self::Log0 => "log0", } } } impl From for Scale { fn from(value: ScaleSpec) -> Self { match value { ScaleSpec::Linear => Self::Linear, ScaleSpec::Log => Self::Log, ScaleSpec::Log0 => Self::LogOrZero { p_zero: LOG0_P_ZERO }, } } } /// 设定值曲线。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ProfileSpec { /// 阶跃。 Step, /// 斜坡 + 保持。 RampAndHold, /// 阶梯。 Staircase, /// 升 → 保持 → 降 → 保持。 UpDown, } impl From for SetpointProfile { fn from(value: ProfileSpec) -> Self { match value { ProfileSpec::Step => Self::Step, ProfileSpec::RampAndHold => Self::RampAndHold, ProfileSpec::Staircase => Self::Staircase { levels: 1 }, ProfileSpec::UpDown => Self::UpDown, } } } /// 局部精修求解器。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SolverSpec { /// Nelder-Mead(无导数)。 NelderMead, /// 粒子群。 Pso, } impl From for SolverKind { fn from(value: SolverSpec) -> Self { match value { SolverSpec::NelderMead => Self::NelderMead, SolverSpec::Pso => Self::Pso, } } } impl SolverSpec { /// 文本名。 #[must_use] pub const fn name(self) -> &'static str { match self { Self::NelderMead => "nelder-mead", Self::Pso => "pso", } } } /// 离线被控对象模型。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum PlantSpec { /// 理想一阶模型(无摩擦、无延迟)。 Gm3508, /// 台架量级:静 / 库仑摩擦 + 2 拍延迟。 Gm3508Bench, } impl PlantSpec { /// 文本名。 #[must_use] pub const fn name(self) -> &'static str { match self { Self::Gm3508 => "gm3508", Self::Gm3508Bench => "gm3508-bench", } } /// 展开为被控对象参数。 #[must_use] pub fn params(self) -> PlantParams { match self { Self::Gm3508 => PlantParams::gm3508(), Self::Gm3508Bench => PlantParams::gm3508_bench(), } } } // ---------------------------------------------------------------- spec 宏 /// 生成"逐项可选 + 逐项覆盖"的预设小节。 /// /// 每个字段都是 `Option`:`None` 表示"本层不表态",由更低优先级来源决定。 /// `overlay` 让 `other` 覆盖 `self` 的非空项。 macro_rules! spec { ( $(#[$outer:meta])* $name:ident { $( $(#[$field_doc:meta])* $field:ident : $ty:ty ),* $(,)? } ) => { $(#[$outer])* #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct $name { $( $(#[$field_doc])* #[serde(default, skip_serializing_if = "Option::is_none")] pub $field: Option<$ty>, )* } impl $name { /// 以 `other` 的非空项覆盖自身(`other` 优先)。 #[must_use] pub fn overlay(&self, other: &Self) -> Self { Self { $($field: other.$field.clone().or_else(|| self.$field.clone()),)* } } } }; } /// 单条增益轴:区间(必填)+ 刻度(可继承)。 #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RangeSpec { /// 下界。 pub min: f64, /// 上界。 pub max: f64, /// 刻度;缺省沿用更低优先级来源(内置默认里四轴都有刻度)。 #[serde(default, skip_serializing_if = "Option::is_none")] pub scale: Option, } impl RangeSpec { /// 只给区间(刻度继承更低优先级来源)。 #[must_use] pub const fn new(min: f64, max: f64) -> Self { Self { min, max, scale: None } } /// 覆盖:区间整体替换,刻度缺省时继承。 #[must_use] pub fn overlay(&self, other: &Self) -> Self { Self { min: other.min, max: other.max, scale: other.scale.or(self.scale) } } } /// 增益参数空间(四轴)。 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SpaceSpec { /// 比例增益区间。 #[serde(default, skip_serializing_if = "Option::is_none")] pub kp: Option, /// 积分增益区间。 #[serde(default, skip_serializing_if = "Option::is_none")] pub ki: Option, /// 微分增益区间。 #[serde(default, skip_serializing_if = "Option::is_none")] pub kd: Option, /// 速度前馈区间。 #[serde(default, skip_serializing_if = "Option::is_none")] pub feedforward: Option, } /// 覆盖单条轴:区间整体替换,刻度继承。 fn overlay_range(base: Option<&RangeSpec>, other: Option<&RangeSpec>) -> Option { match (base, other) { (Some(base), Some(other)) => Some(base.overlay(other)), (None, Some(other)) => Some(*other), (Some(base), None) => Some(*base), (None, None) => None, } } impl SpaceSpec { /// 以 `other` 的非空项覆盖自身(`other` 优先)。 #[must_use] pub fn overlay(&self, other: &Self) -> Self { Self { kp: overlay_range(self.kp.as_ref(), other.kp.as_ref()), ki: overlay_range(self.ki.as_ref(), other.ki.as_ref()), kd: overlay_range(self.kd.as_ref(), other.kd.as_ref()), feedforward: overlay_range(self.feedforward.as_ref(), other.feedforward.as_ref()), } } } spec! { /// 场景。 ScenarioSpec { /// 设定值曲线。 profile: ProfileSpec, /// 目标转子转速(rpm)。 target_rpm: f32, /// 斜坡时间(毫秒)。 ramp_ms: f32, /// 保持时间(毫秒)。 hold_ms: f32, /// 阶梯级数(`profile = "staircase"`)。 stair_levels: u32, /// 控制 / 采样节拍(微秒)。 dt_us: f32, } } spec! { /// 成本权重(各项已归一化)。 CostSpec { /// 调节时间权重。 settle: f64, /// 超调权重。 overshoot: f64, /// IAE 权重。 iae: f64, /// 电流(effort)权重。 effort: f64, } } spec! { /// 验收阈值(决定退出码与报告结论)。 AcceptanceSpec { /// 允许的最大超调(%)。 max_overshoot_pct: f32, /// 允许的最大稳态误差(%)。 max_ss_error_pct: f32, } } spec! { /// 搜索预算与可复现性。 SearchSpec { /// 随机种子。 seed: u64, /// 粗扫样本数。 samples: usize, /// 进入精修的种子数。 top_k: usize, /// 精修求解器。 solver: SolverSpec, /// 每个起点的最大迭代数。 max_iters: usize, /// PSO 粒子数。 particles: usize, /// 评估预算上限。 max_evals: u64, /// 线程数(0 = rayon 默认;真机后端强制 1)。 threads: usize, } } spec! { /// 离线被控对象与域随机化。 SimSpec { /// 名义模型。 plant: PlantSpec, /// 集成成员数(1 = 只用名义模型)。 ensemble: usize, /// 鲁棒权重:0 = 只看均值,1 = 只看最坏成员。 robust_weight: f64, /// 增益抖动 ± 比例。 gain_pct: f32, /// 时间常数抖动 ± 比例。 tau_pct: f32, /// 命令延迟抖动上界(拍)。 delay_ticks_max: u32, /// 摩擦抖动 ± 比例。 friction_pct: f32, } } spec! { /// 真机安全包络与试验节奏。 BenchSpec { /// 过速保护上限(rpm)。 max_rpm: f32, /// 温度保护上限(℃)。 max_temp_c: i8, /// 单次试验超时(毫秒)。 trial_timeout_ms: u64, /// 试验之间的静默期(毫秒)。 between_trials_ms: u64, /// 启动时等待反馈的超时(毫秒)。 startup_timeout_ms: u64, /// 安全事件后是否继续搜索。 continue_on_abort: bool, /// 最优增益的复验次数。 validate_runs: usize, } } // ---------------------------------------------------------------- 预设 /// 一组完整的整定超参数(各小节可缺省)。 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Preset { /// 人类可读说明(报告与 `presets` 子命令展示)。 #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, /// 评估后端。 #[serde(default, skip_serializing_if = "Option::is_none")] pub backend: Option, /// 增益参数空间。 #[serde(default)] pub space: SpaceSpec, /// 场景。 #[serde(default)] pub scenario: ScenarioSpec, /// 成本权重。 #[serde(default)] pub cost: CostSpec, /// 验收阈值。 #[serde(default)] pub acceptance: AcceptanceSpec, /// 搜索预算。 #[serde(default)] pub search: SearchSpec, /// 离线被控对象。 #[serde(default)] pub sim: SimSpec, /// 真机安全包络。 #[serde(default)] pub bench: BenchSpec, } impl Preset { /// 内置默认:与 `chassis-tune` 历史 CLI 默认值逐项一致。 /// /// 这是最低优先级来源,保证任何预设都只需写"与默认不同的部分"。 #[must_use] pub fn builtin() -> Self { Self { description: Some("内置默认(chassis-tune CLI 默认超参数)".into()), backend: Some(BackendSpec::Sim), space: SpaceSpec { kp: Some(RangeSpec { min: 0.5, max: 30.0, scale: Some(ScaleSpec::Log) }), ki: Some(RangeSpec { min: 0.1, max: 100.0, scale: Some(ScaleSpec::Log0) }), kd: Some(RangeSpec { min: 0.01, max: 3.0, scale: Some(ScaleSpec::Log0) }), feedforward: Some(RangeSpec { min: 0.01, max: 2.5, scale: Some(ScaleSpec::Log0), }), }, scenario: ScenarioSpec { profile: Some(ProfileSpec::RampAndHold), target_rpm: Some(1500.0), ramp_ms: Some(200.0), hold_ms: Some(1800.0), stair_levels: Some(4), dt_us: Some(1000.0), }, cost: CostSpec { settle: Some(1.0), overshoot: Some(1.0), iae: Some(0.5), effort: Some(0.1), }, acceptance: AcceptanceSpec { max_overshoot_pct: Some(20.0), max_ss_error_pct: Some(5.0), }, search: SearchSpec { seed: Some(20_260_101), samples: Some(512), top_k: Some(8), solver: Some(SolverSpec::NelderMead), max_iters: Some(200), particles: Some(24), max_evals: Some(4_000), threads: Some(0), }, sim: SimSpec { plant: Some(PlantSpec::Gm3508Bench), ensemble: Some(8), robust_weight: Some(0.5), gain_pct: Some(0.2), tau_pct: Some(0.3), delay_ticks_max: Some(3), friction_pct: Some(0.5), }, bench: BenchSpec { max_rpm: Some(3000.0), max_temp_c: Some(70), trial_timeout_ms: Some(6_000), between_trials_ms: Some(500), startup_timeout_ms: Some(5_000), continue_on_abort: Some(false), validate_runs: Some(3), }, } } /// 以 `other` 的非空项覆盖自身(`other` 优先)。 #[must_use] pub fn overlay(&self, other: &Self) -> Self { Self { description: other.description.clone().or_else(|| self.description.clone()), backend: other.backend.or(self.backend), space: self.space.overlay(&other.space), scenario: self.scenario.overlay(&other.scenario), cost: self.cost.overlay(&other.cost), acceptance: self.acceptance.overlay(&other.acceptance), search: self.search.overlay(&other.search), sim: self.sim.overlay(&other.sim), bench: self.bench.overlay(&other.bench), } } /// 展开为可执行的超参数(含全部校验)。 /// /// # Errors /// /// 缺项([`PresetError::Missing`])或区间 / 阈值 / 预算非法 /// ([`PresetError::Invalid`])时返回错误。 pub fn to_tuning(&self) -> Result { let plant_spec = self.sim.plant.unwrap_or(PlantSpec::Gm3508Bench); Ok(Tuning { backend: self.backend.unwrap_or(BackendSpec::Sim), space: self.gain_space()?, scenario: self.scenario()?, weights: self.weights()?, acceptance: self.acceptance()?, search: self.search()?, plant_spec, plant: plant_spec.params(), tolerances: self.tolerances()?, ensemble: self.ensemble()?, robust_weight: self.robust_weight()?, threads: self.search.threads.unwrap_or(0), }) } /// 由 `--kp` 这类 `MIN..MAX` 文本构造覆盖项。 #[must_use] pub fn range(min: f64, max: f64) -> RangeSpec { RangeSpec { min, max, scale: None } } fn gain_space(&self) -> Result { let space = GainSpace::new( gain_axis("kp", self.space.kp.as_ref())?, gain_axis("ki", self.space.ki.as_ref())?, gain_axis("kd", self.space.kd.as_ref())?, gain_axis("feedforward", self.space.feedforward.as_ref())?, ); space.validate().map_err(|error| PresetError::Invalid { field: "space", reason: error.to_string(), })?; Ok(space) } fn scenario(&self) -> Result { let spec = &self.scenario; let dt_us = spec.dt_us.ok_or(PresetError::Missing { field: "scenario.dt_us" })?; if !(dt_us.is_finite() && dt_us > 0.0) { return Err(PresetError::Invalid { field: "scenario.dt_us", reason: format!("必须为正有限值,得到 {dt_us}"), }); } let target_rpm = spec.target_rpm.ok_or(PresetError::Missing { field: "scenario.target_rpm" })?; if !target_rpm.is_finite() { return Err(PresetError::Invalid { field: "scenario.target_rpm", reason: format!("必须有限,得到 {target_rpm}"), }); } let profile = match spec.profile.unwrap_or(ProfileSpec::RampAndHold) { ProfileSpec::Staircase => { let levels = spec .stair_levels .ok_or(PresetError::Missing { field: "scenario.stair_levels" })?; if levels == 0 { return Err(PresetError::Invalid { field: "scenario.stair_levels", reason: "阶梯级数必须 ≥ 1".into(), }); } SetpointProfile::Staircase { levels } } other => other.into(), }; let scenario = Scenario { dt_s: dt_us / 1_000_000.0, ramp_s: spec.ramp_ms.ok_or(PresetError::Missing { field: "scenario.ramp_ms" })? / 1000.0, hold_s: spec.hold_ms.ok_or(PresetError::Missing { field: "scenario.hold_ms" })? / 1000.0, target_rpm, profile, }; if !(scenario.total_s().is_finite() && scenario.total_s() > 0.0) { return Err(PresetError::Invalid { field: "scenario", reason: "总时长必须为正(检查 ramp_ms / hold_ms)".into(), }); } if !(scenario.ramp_s >= 0.0 && scenario.hold_s >= 0.0) { return Err(PresetError::Invalid { field: "scenario", reason: "ramp_ms / hold_ms 不得为负".into(), }); } Ok(scenario) } fn weights(&self) -> Result { let spec = &self.cost; let value = |field: &'static str, v: Option| -> Result { v.ok_or(PresetError::Missing { field }) }; let weights = Weights { settle: value("cost.settle", spec.settle)?, overshoot: value("cost.overshoot", spec.overshoot)?, iae: value("cost.iae", spec.iae)?, effort: value("cost.effort", spec.effort)?, }; if !weights.is_valid() { return Err(PresetError::Invalid { field: "cost", reason: "权重必须是非负有限值".into(), }); } Ok(weights) } fn acceptance(&self) -> Result { let spec = &self.acceptance; let max_overshoot_pct = spec .max_overshoot_pct .ok_or(PresetError::Missing { field: "acceptance.max_overshoot_pct" })?; let max_ss_error_pct = spec .max_ss_error_pct .ok_or(PresetError::Missing { field: "acceptance.max_ss_error_pct" })?; for (field, value) in [ ("acceptance.max_overshoot_pct", max_overshoot_pct), ("acceptance.max_ss_error_pct", max_ss_error_pct), ] { if !(value.is_finite() && value >= 0.0) { return Err(PresetError::Invalid { field, reason: format!("必须是非负有限值(%),得到 {value}"), }); } } Ok(Acceptance { max_overshoot_pct, max_ss_error_pct }) } fn search(&self) -> Result { let spec = &self.search; let config = SearchConfig { seed: spec.seed.ok_or(PresetError::Missing { field: "search.seed" })?, samples: spec.samples.ok_or(PresetError::Missing { field: "search.samples" })?, top_k: spec.top_k.ok_or(PresetError::Missing { field: "search.top_k" })?, solver: SolverKind::from( spec.solver.ok_or(PresetError::Missing { field: "search.solver" })?, ), max_iters: spec .max_iters .ok_or(PresetError::Missing { field: "search.max_iters" })?, particles: spec .particles .ok_or(PresetError::Missing { field: "search.particles" })?, max_evals: spec .max_evals .ok_or(PresetError::Missing { field: "search.max_evals" })?, }; config.validate().map_err(|error| PresetError::Invalid { field: "search", reason: error.to_string(), })?; Ok(config) } fn tolerances(&self) -> Result { let spec = &self.sim; let tolerances = PlantTolerances { gain_pct: spec.gain_pct.ok_or(PresetError::Missing { field: "sim.gain_pct" })?, tau_pct: spec.tau_pct.ok_or(PresetError::Missing { field: "sim.tau_pct" })?, delay_ticks_max: spec .delay_ticks_max .ok_or(PresetError::Missing { field: "sim.delay_ticks_max" })?, friction_pct: spec .friction_pct .ok_or(PresetError::Missing { field: "sim.friction_pct" })?, }; for (field, value) in [ ("sim.gain_pct", tolerances.gain_pct), ("sim.tau_pct", tolerances.tau_pct), ("sim.friction_pct", tolerances.friction_pct), ] { if !(value.is_finite() && value >= 0.0) { return Err(PresetError::Invalid { field, reason: format!("必须是 ± 比例(非负有限值),得到 {value}"), }); } } Ok(tolerances) } fn ensemble(&self) -> Result { let ensemble = self.sim.ensemble.ok_or(PresetError::Missing { field: "sim.ensemble" })?; if ensemble == 0 { return Err(PresetError::Invalid { field: "sim.ensemble", reason: "集成成员数必须 ≥ 1(1 = 只用名义模型)".into(), }); } Ok(ensemble) } fn robust_weight(&self) -> Result { let weight = self .sim .robust_weight .ok_or(PresetError::Missing { field: "sim.robust_weight" })?; if !(weight.is_finite() && (0.0..=1.0).contains(&weight)) { return Err(PresetError::Invalid { field: "sim.robust_weight", reason: format!("必须在 0..=1 之间,得到 {weight}"), }); } Ok(weight) } /// 真机安全包络参数(`--backend bench` 时才被使用)。 /// /// # Errors /// /// 缺项或数值明显不安全(非正过速上限 / 试验超时等)时返回错误。 pub fn bench_envelope(&self) -> Result { let spec = &self.bench; let envelope = BenchEnvelope { max_rpm: spec.max_rpm.ok_or(PresetError::Missing { field: "bench.max_rpm" })?, max_temp_c: spec .max_temp_c .ok_or(PresetError::Missing { field: "bench.max_temp_c" })?, trial_timeout_ms: spec .trial_timeout_ms .ok_or(PresetError::Missing { field: "bench.trial_timeout_ms" })?, between_trials_ms: spec .between_trials_ms .ok_or(PresetError::Missing { field: "bench.between_trials_ms" })?, startup_timeout_ms: spec .startup_timeout_ms .ok_or(PresetError::Missing { field: "bench.startup_timeout_ms" })?, continue_on_abort: spec.continue_on_abort.unwrap_or(false), validate_runs: spec .validate_runs .ok_or(PresetError::Missing { field: "bench.validate_runs" })?, }; if !(envelope.max_rpm.is_finite() && envelope.max_rpm > 0.0) { return Err(PresetError::Invalid { field: "bench.max_rpm", reason: format!("过速上限必须为正有限值,得到 {}", envelope.max_rpm), }); } if envelope.trial_timeout_ms == 0 { return Err(PresetError::Invalid { field: "bench.trial_timeout_ms", reason: "试验超时必须 > 0(0 会让试验立刻被判超时)".into(), }); } if envelope.startup_timeout_ms == 0 { return Err(PresetError::Invalid { field: "bench.startup_timeout_ms", reason: "启动等待超时必须 > 0".into(), }); } if envelope.validate_runs == 0 { return Err(PresetError::Invalid { field: "bench.validate_runs", reason: "复验次数必须 ≥ 1".into(), }); } Ok(envelope) } } /// 把一条轴定义展开并校验为 [`GainAxis`]。 /// /// # Errors /// /// 轴缺失、缺少刻度、区间非法(`min > max`、对数轴下界 ≤ 0)时返回错误。 fn gain_axis(name: &'static str, spec: Option<&RangeSpec>) -> Result { let spec = spec.ok_or(PresetError::Missing { field: name })?; let scale = match spec.scale { Some(scale) => Scale::from(scale), None => { return Err(PresetError::Invalid { field: name, reason: "缺少 scale(应为 linear / log / log0)".into(), }); } }; let axis = GainAxis::new(name, spec.min, spec.max, scale); axis.validate() .map_err(|error| PresetError::Invalid { field: name, reason: error.to_string() })?; Ok(axis) } /// 解算后的验收阈值。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct Acceptance { /// 允许的最大超调(%)。 pub max_overshoot_pct: f32, /// 允许的最大稳态误差(%)。 pub max_ss_error_pct: f32, } /// 解算后的真机安全包络参数。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct BenchEnvelope { /// 过速保护上限(rpm)。 pub max_rpm: f32, /// 温度保护上限(℃)。 pub max_temp_c: i8, /// 单次试验超时(毫秒)。 pub trial_timeout_ms: u64, /// 试验之间的静默期(毫秒)。 pub between_trials_ms: u64, /// 启动时等待反馈的超时(毫秒)。 pub startup_timeout_ms: u64, /// 安全事件后是否继续搜索。 pub continue_on_abort: bool, /// 复验次数。 pub validate_runs: usize, } /// 解算后的一组超参数:可直接喂给搜索与后端。 #[derive(Debug, Clone, PartialEq)] pub struct Tuning { /// 评估后端。 pub backend: BackendSpec, /// 增益参数空间。 pub space: GainSpace, /// 场景。 pub scenario: Scenario, /// 成本权重。 pub weights: Weights, /// 验收阈值。 pub acceptance: Acceptance, /// 搜索配置。 pub search: SearchConfig, /// 名义被控对象(枚举,供显示 / 复现)。 pub plant_spec: PlantSpec, /// 名义被控对象(展开后的参数,供仿真重放)。 pub plant: PlantParams, /// 域随机化幅度。 pub tolerances: PlantTolerances, /// 集成成员数。 pub ensemble: usize, /// 鲁棒权重。 pub robust_weight: f64, /// 线程数(0 = 自动)。 pub threads: usize, } // ---------------------------------------------------------------- 文件 /// 预设文件:版本 + 默认预设名 + 具名预设集合。 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PresetFile { /// schema 版本,必须等于 [`PRESET_VERSION`]。 pub version: u32, /// 未显式指定名字时使用的预设(别名 `default`)。 #[serde(default, alias = "default", skip_serializing_if = "Option::is_none")] pub default_preset: Option, /// 具名预设。 #[serde(default)] pub preset: BTreeMap, } impl PresetFile { /// 内置库:只含 `default`(等价于 [`Preset::builtin`])。 /// /// 具名工况预设放在 `config/tune.toml` 里评审,从而避免"同一组数字在 /// Rust 与 TOML 里各写一份"的漂移。 #[must_use] pub fn builtin() -> Self { let mut preset = BTreeMap::new(); preset.insert("default".to_owned(), Preset::builtin()); Self { version: PRESET_VERSION, default_preset: Some("default".to_owned()), preset } } /// 从文件读取并校验。 /// /// # Errors /// /// 读取失败([`PresetError::Io`])、TOML / schema 非法 /// ([`PresetError::Load`])或版本不支持时返回错误。 pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path) .map_err(|source| PresetError::Io { path: path.to_path_buf(), source })?; let file: Self = toml::from_str(&text) .map_err(|source| PresetError::Load { path: path.to_path_buf(), source })?; file.validate()?; Ok(file) } /// 从文本解析并校验(测试与内存场景用)。 /// /// # Errors /// /// TOML / schema 非法或版本不支持时返回错误。 pub fn parse_str(text: &str) -> Result { let file: Self = toml::from_str(text).map_err(|source| PresetError::Parse { source })?; file.validate()?; Ok(file) } /// 校验版本与内容。 /// /// # Errors /// /// 版本不等于 [`PRESET_VERSION`] 或没有任何预设时返回错误。 pub fn validate(&self) -> Result<(), PresetError> { if self.version != PRESET_VERSION { return Err(PresetError::UnsupportedVersion { found: self.version, expected: PRESET_VERSION, }); } if self.preset.is_empty() { return Err(PresetError::Empty); } Ok(()) } /// 预设名列表(字典序)。 #[must_use] pub fn names(&self) -> Vec { self.preset.keys().cloned().collect() } /// 选择一个预设:显式名字 > `default_preset` > 唯一预设。 /// /// # Errors /// /// 文件为空、名字不存在、或存在多个预设却没有 `default_preset` 时返回错误。 pub fn select(&self, name: Option<&str>) -> Result<&Preset, PresetError> { self.select_named(name).map(|(_, preset)| preset) } /// 同 [`Self::select`],但把**生效名**一并返回(报告里要写清"这批数出自哪个预设")。 /// /// # Errors /// /// 同 [`Self::select`]。 pub fn select_named(&self, name: Option<&str>) -> Result<(&str, &Preset), PresetError> { if self.preset.is_empty() { return Err(PresetError::Empty); } let key = match name { Some(name) => name, None => match self.default_preset.as_deref() { Some(default) => default, None if self.preset.len() == 1 => { self.preset.keys().next().map(String::as_str).unwrap_or_default() } None => return Err(PresetError::NoDefault { available: self.names() }), }, }; self.preset.get_key_value(key).map_or_else( || { Err(PresetError::UnknownPreset { name: key.to_owned(), available: self.names(), }) }, |(stored, preset)| Ok((stored.as_str(), preset)), ) } } // ---------------------------------------------------------------- 错误 /// 预设加载 / 解算错误。 #[derive(Debug, Error)] pub enum PresetError { /// 文件读取失败。 #[error("读取预设文件 {path} 失败:{source}")] Io { /// 路径。 path: PathBuf, /// 底层 IO 错误。 #[source] source: std::io::Error, }, /// 文件解析失败(带路径)。 #[error("解析预设文件 {path} 失败:{source}")] Load { /// 路径。 path: PathBuf, /// TOML 错误。 #[source] source: toml::de::Error, }, /// 文本解析失败。 #[error("解析预设 TOML 失败:{source}")] Parse { /// TOML 错误。 #[source] source: toml::de::Error, }, /// schema 版本不支持。 #[error("预设文件 version = {found} 不受支持(本工具支持 version = {expected})")] UnsupportedVersion { /// 文件里的版本。 found: u32, /// 支持的版本。 expected: u32, }, /// 没有任何预设。 #[error("预设文件里没有任何预设")] Empty, /// 名字不存在。 #[error("预设 '{name}' 不存在(可用:{})", available.join(", "))] UnknownPreset { /// 请求的名字。 name: String, /// 可选名字。 available: Vec, }, /// 多个预设但没有默认名。 #[error("预设文件有多个预设但未指定 default_preset(请用 --preset-name 指定,可选:{})", available.join(", "))] NoDefault { /// 可选名字。 available: Vec, }, /// 缺少必需项。 #[error("超参数 '{field}' 缺失(预设与内置默认都没有提供)")] Missing { /// 项名。 field: &'static str, }, /// 项值非法。 #[error("超参数 '{field}' 非法:{reason}")] Invalid { /// 项名。 field: &'static str, /// 原因。 reason: String, }, } #[cfg(test)] mod tests { use super::*; const MINIMAL: &str = r#" version = 1 default_preset = "fast" [preset.fast] description = "小样本快跑" [preset.fast.search] samples = 32 top_k = 3 "#; #[test] fn builtin_satisfies_its_own_schema() { let text = toml::to_string(&PresetFile::builtin()).unwrap(); let round_trip = PresetFile::parse_str(&text).unwrap(); assert_eq!(round_trip, PresetFile::builtin()); assert!(round_trip.select(None).unwrap().to_tuning().is_ok()); } #[test] fn builtin_matches_the_historical_cli_defaults() { // 这些数字是 chassis-tune 历史的 CLI 默认值,改动等于改变整定的默认行为, // 必须是有意为之。 let tuning = Preset::builtin().to_tuning().unwrap(); 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.space.kp.scale, Scale::Log); assert_eq!(tuning.space.ki.scale, Scale::LogOrZero { p_zero: LOG0_P_ZERO }); assert!((tuning.scenario.target_rpm - 1500.0).abs() < 1e-6); assert!((tuning.scenario.dt_s - 0.001).abs() < 1e-9); assert_eq!(tuning.weights, Weights::default()); assert!((tuning.acceptance.max_overshoot_pct - 20.0).abs() < 1e-6); assert_eq!(tuning.search, SearchConfig::default()); assert_eq!(tuning.ensemble, 8); assert!((tuning.robust_weight - 0.5).abs() < 1e-12); assert_eq!(tuning.tolerances, PlantTolerances::default()); let envelope = Preset::builtin().bench_envelope().unwrap(); assert!((envelope.max_rpm - 3000.0).abs() < 1e-6); assert_eq!(envelope.validate_runs, 3); } #[test] fn preset_overlays_only_the_fields_it_sets() { let file = PresetFile::parse_str(MINIMAL).unwrap(); let merged = Preset::builtin().overlay(file.select(None).unwrap()); let tuning = merged.to_tuning().unwrap(); assert_eq!(tuning.search.samples, 32, "预设必须生效"); assert_eq!(tuning.search.top_k, 3); // 未提及的项沿用内置默认。 assert_eq!(tuning.search.seed, SearchConfig::default().seed); assert_eq!(tuning.search.max_evals, SearchConfig::default().max_evals); assert!((tuning.space.kp.max - 30.0).abs() < 1e-12); assert_eq!(merged.description.as_deref(), Some("小样本快跑")); } #[test] fn range_overlay_keeps_the_inherited_scale() { let mut overlay = Preset::default(); overlay.space.kp = Some(RangeSpec::new(1.0, 5.0)); let merged = Preset::builtin().overlay(&overlay); let tuning = merged.to_tuning().unwrap(); assert!((tuning.space.kp.min - 1.0).abs() < 1e-12); assert!((tuning.space.kp.max - 5.0).abs() < 1e-12); assert_eq!(tuning.space.kp.scale, Scale::Log, "刻度必须从内置默认继承"); } #[test] fn overlay_does_not_mutate_the_base() { let base = Preset::builtin(); let mut overlay = Preset::default(); overlay.search.samples = Some(1); let _ = base.overlay(&overlay); assert_eq!(base.search.samples, Some(512), "覆盖必须是纯函数"); } #[test] fn select_prefers_explicit_name_then_default_then_single() { let file = PresetFile::parse_str(MINIMAL).unwrap(); assert!(file.select(Some("fast")).is_ok()); assert!(file.select(None).is_ok(), "default_preset 必须生效"); let single = PresetFile::parse_str( "version = 1\n[preset.only]\n[preset.only.search]\nsamples = 2\n", ) .unwrap(); assert!(single.select(None).is_ok(), "唯一预设无需 default_preset"); let many = PresetFile::parse_str("version = 1\n[preset.a]\n[preset.b]\n").unwrap(); assert!(matches!(many.select(None), Err(PresetError::NoDefault { .. }))); } #[test] fn unknown_preset_lists_the_available_names() { let file = PresetFile::parse_str(MINIMAL).unwrap(); let error = file.select(Some("nope")).unwrap_err(); let text = error.to_string(); assert!(text.contains("nope"), "{text}"); assert!(text.contains("fast"), "错误信息必须列出可用名:{text}"); } #[test] fn unsupported_version_is_rejected() { let text = "version = 99\n[preset.a]\n"; assert!(matches!( PresetFile::parse_str(text), Err(PresetError::UnsupportedVersion { found: 99, expected: PRESET_VERSION }) )); } #[test] fn empty_and_unknown_keys_are_rejected() { assert!(matches!(PresetFile::parse_str("version = 1\n"), Err(PresetError::Empty))); // 拼错小节名必须报错,而不是被静默忽略(否则"以为改了其实没改")。 let typo = "[preset.a.space]\nkpp = { min = 1.0, max = 2.0 }\n"; assert!(matches!(PresetFile::parse_str(typo), Err(PresetError::Parse { .. }))); let typo2 = "version = 1\n[preset.a.searc]\nsamples = 1\n"; assert!(matches!(PresetFile::parse_str(typo2), Err(PresetError::Parse { .. }))); } #[test] fn load_reports_the_path_for_missing_files() { let path = std::path::Path::new("/definitely/not/here/tune.toml"); let error = PresetFile::load(path).unwrap_err(); assert!(matches!(error, PresetError::Io { .. })); assert!(error.to_string().contains("tune.toml"), "{error}"); } #[test] fn invalid_values_are_rejected_with_the_offending_key() { // 在**完整**预设(内置默认 + 覆盖)上校验:预设只写要改的项。 let build = |body: &str| -> Result { let text = format!("version = 1\n[preset.a]\n{body}"); let file = PresetFile::parse_str(&text)?; Preset::builtin().overlay(file.select(None)?).to_tuning() }; let error = build("[preset.a.space]\nkp = { min = -1.0, max = 5.0, scale = \"log\" }\n") .unwrap_err(); assert!(matches!(&error, PresetError::Invalid { field: "kp", .. }), "{error}"); // 负权重。 let error = build("[preset.a.cost]\nsettle = -1.0\n").unwrap_err(); assert!(matches!(&error, PresetError::Invalid { field: "cost", .. }), "{error}"); // 零样本(唯一被 `SearchConfig::validate` 拒绝的搜索配置)。 let error = build("[preset.a.search]\nsamples = 0\n").unwrap_err(); assert!(matches!(&error, PresetError::Invalid { field: "search", .. }), "{error}"); // 预算小于样本数**不是**错误:sweep 会把预算下限抬到 samples。 assert!(build("[preset.a.search]\nsamples = 100\nmax_evals = 10\n").is_ok()); // 鲁棒权重越界。 let error = build("[preset.a.sim]\nrobust_weight = 1.5\n").unwrap_err(); assert!( matches!(&error, PresetError::Invalid { field: "sim.robust_weight", .. }), "{error}" ); // 退化场景。 let error = build("[preset.a.scenario]\nhold_ms = 0.0\nramp_ms = 0.0\nprofile = \"step\"\n") .unwrap_err(); assert!(matches!(&error, PresetError::Invalid { field: "scenario", .. }), "{error}"); // 缺 scale 的轴(内置换行默认提供了刻度,这里显式抹掉)。 let mut preset = Preset::builtin(); preset.space.kd = Some(RangeSpec { min: 0.1, max: 1.0, scale: None }); let error = preset.to_tuning().unwrap_err(); assert!(matches!(&error, PresetError::Invalid { field: "kd", .. }), "{error}"); } #[test] fn missing_keys_are_reported_for_a_bare_preset() { let error = Preset::default().to_tuning().unwrap_err(); assert!(matches!(error, PresetError::Missing { .. }), "{error}"); } #[test] fn staircase_needs_levels() { let mut preset = Preset::builtin(); preset.scenario.profile = Some(ProfileSpec::Staircase); preset.scenario.stair_levels = None; assert!(matches!( preset.to_tuning(), Err(PresetError::Missing { field: "scenario.stair_levels" }) )); preset.scenario.stair_levels = Some(0); assert!(matches!( preset.to_tuning(), Err(PresetError::Invalid { field: "scenario.stair_levels", .. }) )); preset.scenario.stair_levels = Some(5); assert!(preset.to_tuning().is_ok()); } #[test] fn bench_envelope_rejects_unsafe_values() { let mut preset = Preset::builtin(); preset.bench.max_rpm = Some(0.0); assert!(matches!( preset.bench_envelope(), Err(PresetError::Invalid { field: "bench.max_rpm", .. }) )); preset = Preset::builtin(); preset.bench.trial_timeout_ms = Some(0); assert!(preset.bench_envelope().is_err()); preset = Preset::builtin(); preset.bench.validate_runs = Some(0); assert!(preset.bench_envelope().is_err()); } #[test] fn spec_enums_round_trip_through_toml_text() { #[derive(Deserialize)] struct Wrapper { value: T, } let parse = |text: &str| -> Wrapper { toml::from_str(&format!("value = \"{text}\"")).unwrap() }; for (spec, text) in [(ScaleSpec::Linear, "linear"), (ScaleSpec::Log, "log"), (ScaleSpec::Log0, "log0")] { assert_eq!(spec.name(), text); assert_eq!(parse(text).value, spec); } assert_eq!(PlantSpec::Gm3508Bench.name(), "gm3508-bench"); assert_eq!(SolverSpec::NelderMead.name(), "nelder-mead"); assert_eq!(BackendSpec::Bench.name(), "bench"); let profile: Wrapper = toml::from_str("value = \"ramp-and-hold\"").unwrap(); assert_eq!(profile.value, ProfileSpec::RampAndHold); } } } pub mod search { //! 两阶段参数空间搜索:种子化分层粗扫 → `argmin` 多起点局部精修。 //! //! # 为什么是两阶段 //! //! 成本函数有一大片"罚值平原"(不收敛 / 饱和的候选成本都等于 `PENALTY`), //! 纯局部搜索会陷在平原上;而纯网格搜索在四个数量级跨度上需要天文数字的 //! 评估次数。两阶段各取所长: //! //! 1. **粗扫**:Latin Hypercube 分层采样 + `rayon` 并行评估,用有限次数把 //! 空间铺满,拿到若干个互不相邻的可达解(top-k); //! 2. **精修**:以每个可达解为起点做多起点 `argmin` 局部搜索(Nelder-Mead //! 或 PSO),在**坐标空间**(对数刻度)里收敛到局部最优。 //! //! # 可复现性 //! //! 每个候选的随机性只来自 `(种子, 候选下标)` 派生的独立 RNG,结果与线程 //! 调度无关;粗扫结果按下标排序后再取 top-k,因此同 seed 两次运行逐位一致。 use std::sync::atomic::{AtomicU64, Ordering}; use argmin::core::{CostFunction, Error as ArgminError, Executor}; use argmin::solver::neldermead::NelderMead; use argmin::solver::particleswarm::ParticleSwarm; use experiment::Scenario; use indicatif::ProgressBar; use motor_control::VelocityGains; use rand::SeedableRng as _; use rand::rngs::StdRng; use rayon::prelude::*; use thiserror::Error; use crate::Evaluator; use crate::cost::{Score, Verdict}; use crate::gains::{AXIS_COUNT, GainSpace}; /// 搜索阶段的错误。 #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum SearchError { /// 参数空间非法。 #[error("invalid gain space: {0}")] InvalidSpace(String), /// 粗扫样本数为 0。 #[error("samples must be >= 1")] NoSamples, } /// 候选来自哪个阶段。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Phase { /// 分层粗扫。 Coarse, /// 局部精修。 Refine, /// 用户指定 / 基线点。 Seed, } impl Phase { /// CSV / 报告标签。 #[must_use] pub fn label(self) -> &'static str { match self { Self::Coarse => "coarse", Self::Refine => "refine", Self::Seed => "seed", } } } /// 一次被评估的候选。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct Trial { /// 评估序号(按评估顺序递增,报告可复现)。 pub index: u64, /// 所属阶段。 pub phase: Phase, /// 增益。 pub gains: VelocityGains, /// 评估结果。 pub score: Score, } impl Trial { /// 成本。 #[must_use] pub fn cost(&self) -> f64 { self.score.cost } } /// 局部精修求解器。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SolverKind { /// Nelder-Mead(无导数、对罚值平原鲁棒),默认。 #[default] NelderMead, /// 粒子群(更全局,评估次数更高)。 Pso, } impl SolverKind { /// 文本名。 #[must_use] pub fn name(self) -> &'static str { match self { Self::NelderMead => "nelder-mead", Self::Pso => "pso", } } /// 从文本解析。 /// /// # Errors /// /// 未知名字时返回错误。 pub fn parse(text: &str) -> Result { match text { "nelder-mead" | "neldermead" | "nm" => Ok(Self::NelderMead), "pso" | "particle-swarm" => Ok(Self::Pso), other => Err(SearchError::InvalidSpace(format!( "unknown solver '{other}' (expected nelder-mead|pso)" ))), } } } /// 搜索配置。 #[derive(Debug, Clone, PartialEq)] pub struct SearchConfig { /// 种子(决定分层采样与多起点顺序)。 pub seed: u64, /// 粗扫样本数。 pub samples: usize, /// 进入精修的种子数量。 pub top_k: usize, /// 精修求解器。 pub solver: SolverKind, /// 每个起点的最大迭代数。 pub max_iters: usize, /// PSO 粒子数(`SolverKind::Pso` 时生效)。 pub particles: usize, /// 评估预算上限(含粗扫)。 pub max_evals: u64, } impl Default for SearchConfig { fn default() -> Self { Self { seed: 20_260_101, samples: 512, top_k: 8, solver: SolverKind::NelderMead, max_iters: 200, particles: 24, max_evals: 4_000, } } } impl SearchConfig { /// 校验。 /// /// 预算小于样本数**不是**错误:[`sweep`] 会把预算下限抬到 `samples` /// (粗扫必须能把铺满空间的样本跑完),因此只有"没有样本"才是硬错误。 /// /// # Errors /// /// 样本数为 0 时返回 [`SearchError::NoSamples`]。 pub fn validate(&self) -> Result<(), SearchError> { if self.samples == 0 { return Err(SearchError::NoSamples); } Ok(()) } } /// 搜索结果(按成本升序)。 #[derive(Debug, Clone)] pub struct Ranked { /// 全部候选(升序)。 pub trials: Vec, /// 最优候选。 pub best: Trial, /// 实际评估次数。 pub evals: u64, /// 被硬约束拦下的候选数。 pub rejected: usize, } impl Ranked { /// 前 `n` 名。 #[must_use] pub fn top(&self, n: usize) -> &[Trial] { let n = n.min(self.trials.len()); &self.trials[..n] } /// 达到 `Verdict::Ok` 的候选数。 #[must_use] pub fn ok_count(&self) -> usize { self.trials.iter().filter(|t| t.score.verdict.is_ok()).count() } } /// 共享的评估预算与计数器。 struct Budget { used: AtomicU64, limit: u64, } impl Budget { fn new(limit: u64) -> Self { Self { used: AtomicU64::new(0), limit } } /// 取一个评估配额;返回 `None` 表示预算耗尽。 /// /// 用 CAS 而不是 `fetch_add`:预算耗尽后优化器仍会持续询问,若仍自增, /// `spent()` 就会把"被拒绝的询问"也算成评估次数,报告里的数字便失去意义。 fn take(&self) -> Option { let mut current = self.used.load(Ordering::Relaxed); loop { if current >= self.limit { return None; } match self.used.compare_exchange_weak( current, current + 1, Ordering::Relaxed, Ordering::Relaxed, ) { Ok(_) => return Some(current), Err(observed) => current = observed, } } } fn spent(&self) -> u64 { self.used.load(Ordering::Relaxed) } } /// 按预算评估一组增益(预算耗尽时直接给罚值,不再花时间算)。 fn evaluate_with_budget( budget: &Budget, evaluator: &dyn Evaluator, gains: &VelocityGains, ) -> Score { if budget.take().is_some() { evaluator.evaluate(gains) } else { Score::penalized(Verdict::Rejected) } } /// 记录本次精修见过的最优解。 /// /// 不依赖 `argmin` 的结果访问器(跨版本不稳定),而是让成本函数自己记账: /// 谁算出过最低成本,谁就是这次精修的结果。 #[derive(Default)] struct BestTracker { best: std::sync::Mutex>, } impl BestTracker { fn new() -> Self { Self::default() } fn record(&self, cost: f64, coords: [f64; AXIS_COUNT]) { let mut best = self.best.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if best.is_none_or(|(current, _)| cost < current) { *best = Some((cost, coords)); } } fn take(&self) -> Option<(f64, [f64; AXIS_COUNT])> { *self.best.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } } /// 把 [`Evaluator`] 适配成 `argmin` 的成本函数(坐标空间参数)。 struct CoordProblem<'a> { evaluator: &'a dyn Evaluator, space: &'a GainSpace, budget: &'a Budget, tracker: &'a BestTracker, } impl CostFunction for CoordProblem<'_> { type Param = Vec; type Output = f64; 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), }; let gains = self.space.from_coords(coords); let cost = match self.budget.take() { Some(_) => self.evaluator.evaluate(&gains).cost, None => crate::cost::PENALTY, }; if cost.is_finite() { self.tracker.record(cost, coords); } Ok(cost) } } /// 以 `start` 为起点做一次局部精修,返回见过的最优增益。 fn refine( space: &GainSpace, evaluator: &dyn Evaluator, budget: &Budget, config: &SearchConfig, start: VelocityGains, seed: u64, ) -> VelocityGains { let (lower, upper) = space.coord_bounds(); let tracker = BestTracker::new(); let problem = CoordProblem { evaluator, space, budget, tracker: &tracker }; let start_coords = space.to_coords(&start); // 两种求解器的 `OptimizationResult` 类型不同,这里只保留"成功 / 失败"。 let outcome: Result<(), ArgminError> = match config.solver { SolverKind::NelderMead => { // n 维需要 n+1 个初始顶点:起点 + 每轴一小步(轴跨度的 5%)。 let mut simplex: Vec> = Vec::with_capacity(AXIS_COUNT + 1); simplex.push(start_coords.to_vec()); for axis in 0..AXIS_COUNT { let mut vertex = start_coords; let span = (upper[axis] - lower[axis]).abs(); vertex[axis] = (vertex[axis] + 0.05 * span.max(1.0e-3)).min(upper[axis]); simplex.push(vertex.to_vec()); } let solver: NelderMead, f64> = NelderMead::new(simplex); Executor::new(problem, solver) .configure(|state| state.max_iters(config.max_iters as u64)) .run() .map(|_| ()) } SolverKind::Pso => { let solver: ParticleSwarm, f64, StdRng> = ParticleSwarm::new((lower.to_vec(), upper.to_vec()), config.particles.max(2)) .with_rng_generator(StdRng::seed_from_u64(seed)); Executor::new(problem, solver) .configure(|state| state.max_iters(config.max_iters as u64)) .run() .map(|_| ()) } }; // 求解器失败(预算耗尽 / 数值问题)不算错:退化到起点,搜索继续。 if let Err(error) = outcome { tracing::debug!(%error, "local refinement stopped early"); } tracker.take().map_or(start, |(_, coords)| space.from_coords(coords)) } /// 执行两阶段搜索。 /// /// # Errors /// /// 参数空间或配置非法时返回 [`SearchError`]。 pub fn sweep( space: &GainSpace, config: &SearchConfig, evaluator: &dyn Evaluator, progress: Option<&ProgressBar>, ) -> Result { space.validate().map_err(|e| SearchError::InvalidSpace(e.to_string()))?; config.validate()?; let budget = Budget::new(config.max_evals.max(config.samples as u64)); // ---------------- 阶段 1:分层粗扫(并行) ---------------- let mut rng = StdRng::seed_from_u64(config.seed); let candidates = space.stratified(config.samples, &mut rng); let mut trials: Vec = candidates .into_par_iter() .enumerate() .map(|(index, gains)| Trial { index: index as u64, phase: Phase::Coarse, gains, score: evaluate_with_budget(&budget, evaluator, &gains), }) .collect(); if let Some(bar) = progress { bar.inc(trials.len() as u64); } // 排序后再取 top-k:与线程调度无关,同 seed 必定选出同一批种子。 trials.sort_by(|a, b| a.cost().total_cmp(&b.cost()).then(a.index.cmp(&b.index))); let seeds: Vec = trials.iter().take(config.top_k.max(1)).copied().collect(); // ---------------- 阶段 2:多起点精修(并行) ---------------- let coarse_count = trials.len() as u64; let refined: Vec = seeds .par_iter() .enumerate() .map(|(slot, seed_trial)| { let seed = config.seed.wrapping_add((slot as u64 + 1).wrapping_mul(0x9E37_79B9)); let gains = refine(space, evaluator, &budget, config, seed_trial.gains, seed); let score = evaluate_with_budget(&budget, evaluator, &gains); Trial { index: coarse_count + slot as u64, phase: Phase::Refine, gains, score } }) .collect(); if let Some(bar) = progress { bar.inc(refined.len() as u64); } trials.extend(refined); trials.sort_by(|a, b| a.cost().total_cmp(&b.cost()).then(a.index.cmp(&b.index))); let evals = budget.spent(); let rejected = trials.iter().filter(|t| !t.score.verdict.is_ok()).count(); let best = trials.first().copied().ok_or(SearchError::NoSamples)?; Ok(Ranked { trials, best, evals, rejected }) } /// 只评估一组增益(`validate` 子命令)。 #[must_use] pub fn evaluate_once(evaluator: &dyn Evaluator, gains: &VelocityGains) -> Trial { Trial { index: 0, phase: Phase::Seed, gains: *gains, score: evaluator.evaluate(gains) } } /// 一次评估都未达标时的提示(报告用)。 #[must_use] pub fn verdict_hint(verdict: Verdict) -> &'static str { match verdict { Verdict::Ok => "达标", Verdict::Rejected => "参数非法或场景退化", Verdict::Diverged => "数值发散:增益过大", Verdict::Overspeed => "过速:增益过大或前馈过高", Verdict::Saturated => "长时间饱和:限流太紧或积分过大", Verdict::NoSettle => "未收敛:增益过小或积分不足(空载台架考虑 ff=0)", } } /// 报告用的场景摘要。 #[must_use] pub fn scenario_summary(scenario: &Scenario) -> String { format!( "{:?} target={:.0} rpm ramp={:.0}ms hold={:.0}ms dt={:.1}ms", scenario.profile, scenario.target_rpm, scenario.ramp_s * 1000.0, scenario.hold_s * 1000.0, scenario.dt_s * 1000.0 ) } #[cfg(test)] mod tests { use super::*; use crate::TestSimEvaluator; use crate::cost::{Objective, Weights}; use motor_model::PlantParams; use sim_harness::{LoopLimits, PlantEnsemble, PlantTolerances}; fn space() -> GainSpace { GainSpace::default_gm3508() } fn evaluator(plant: PlantParams, ensemble: usize, robust_weight: f64) -> TestSimEvaluator { let scenario = Scenario::step(1500.0, 2.0); let limits = LoopLimits::gm3508(); let objective = Objective::new(Weights::default(), scenario, limits); let ensemble = PlantEnsemble::randomized(plant, PlantTolerances::default(), ensemble, 7); TestSimEvaluator::new(ensemble, scenario, limits, objective, robust_weight) } fn small_config(seed: u64) -> SearchConfig { SearchConfig { seed, samples: 64, top_k: 3, max_iters: 60, max_evals: 2_000, ..SearchConfig::default() } } #[test] fn sweep_finds_a_usable_configuration() { let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0); let ranked = sweep(&space(), &small_config(1), &evaluator, None).unwrap(); let best = ranked.best; assert_eq!(best.score.verdict, Verdict::Ok, "{best:?}"); let metrics = best.score.metrics.unwrap(); assert!(metrics.ss_error_pct < 5.0, "ss = {}", metrics.ss_error_pct); assert!(metrics.overshoot_pct < 30.0, "overshoot = {}", metrics.overshoot_pct); } #[test] fn sweep_beats_the_documented_hand_tuned_baseline() { let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0); // README 记录的空载台架起步整定:kp=2, ki=10, kd=0, ff=0。 let baseline = evaluator.evaluate(&VelocityGains::new(2.0, 10.0, 0.0, 0.0)); let ranked = sweep(&space(), &small_config(17), &evaluator, None).unwrap(); assert!( ranked.best.cost() < baseline.cost, "search {} must beat baseline {}", ranked.best.cost(), baseline.cost ); } #[test] fn results_are_reproducible_for_a_fixed_seed() { let evaluator = evaluator(PlantParams::gm3508_bench(), 4, 0.5); let a = sweep(&space(), &small_config(99), &evaluator, None).unwrap(); let b = sweep(&space(), &small_config(99), &evaluator, None).unwrap(); assert_eq!(a.best.gains, b.best.gains, "同 seed 必须给出同一组增益"); assert_eq!(a.best.cost().to_bits(), b.best.cost().to_bits()); assert_eq!(a.trials.len(), b.trials.len()); for (x, y) in a.trials.iter().zip(&b.trials) { assert_eq!(x.gains, y.gains); assert_eq!(x.cost().to_bits(), y.cost().to_bits()); } } #[test] fn different_seeds_explore_differently() { let evaluator = evaluator(PlantParams::gm3508_bench(), 2, 0.5); let a = sweep(&space(), &small_config(1), &evaluator, None).unwrap(); let b = sweep(&space(), &small_config(2), &evaluator, None).unwrap(); let same_phase1: Vec<_> = a.trials.iter().filter(|t| t.phase == Phase::Coarse).map(|t| t.gains).collect(); let other: Vec<_> = b.trials.iter().filter(|t| t.phase == Phase::Coarse).map(|t| t.gains).collect(); assert_ne!(same_phase1, other); } #[test] fn refined_trials_are_not_worse_than_their_seed() { let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0); let ranked = sweep(&space(), &small_config(5), &evaluator, None).unwrap(); let coarse_best = ranked .trials .iter() .filter(|t| t.phase == Phase::Coarse) .map(Trial::cost) .fold(f64::INFINITY, f64::min); assert!( ranked.best.cost() <= coarse_best + 1e-9, "best {} vs coarse best {coarse_best}", ranked.best.cost() ); assert!(ranked.trials.iter().any(|t| t.phase == Phase::Refine)); } #[test] fn pso_solver_also_improves_or_holds() { let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0); let mut config = small_config(3); config.solver = SolverKind::Pso; config.particles = 8; config.max_iters = 12; let ranked = sweep(&space(), &config, &evaluator, None).unwrap(); assert_eq!(ranked.best.score.verdict, Verdict::Ok, "{:?}", ranked.best); } #[test] fn budget_bounds_the_number_of_evaluations() { let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0); let mut config = small_config(4); config.max_evals = 80; let ranked = sweep(&space(), &config, &evaluator, None).unwrap(); assert!(ranked.evals <= 84, "evals = {}", ranked.evals); } #[test] fn invalid_space_is_rejected() { let mut bad = space(); bad.kp.min = -1.0; let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0); assert!(matches!( sweep(&bad, &small_config(1), &evaluator, None), Err(SearchError::InvalidSpace(_)) )); } #[test] fn zero_samples_is_rejected() { let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0); let mut config = small_config(1); config.samples = 0; assert_eq!( sweep(&space(), &config, &evaluator, None).err(), Some(SearchError::NoSamples) ); } #[test] fn solver_kind_parsing_round_trips() { assert_eq!(SolverKind::parse("nelder-mead").unwrap(), SolverKind::NelderMead); assert_eq!(SolverKind::parse("pso").unwrap(), SolverKind::Pso); assert!(SolverKind::parse("simulated-annealing").is_err()); assert_eq!(SolverKind::default().name(), "nelder-mead"); } #[test] fn evaluate_once_reports_the_same_verdict() { let evaluator = evaluator(PlantParams::gm3508(), 1, 0.0); let trial = evaluate_once(&evaluator, &VelocityGains::new(4.0, 12.0, 0.0, 0.0)); assert_eq!(trial.phase, Phase::Seed); assert_eq!(trial.score.verdict, Verdict::Ok); assert!(trial.score.metrics.is_some()); } #[test] fn verdict_hints_cover_every_verdict() { for verdict in [ Verdict::Ok, Verdict::Rejected, Verdict::Diverged, Verdict::Overspeed, Verdict::Saturated, Verdict::NoSettle, ] { assert!(!verdict_hint(verdict).is_empty()); } } #[test] fn scenario_summary_mentions_the_key_numbers() { let text = scenario_summary(&Scenario::step(1200.0, 1.0)); assert!(text.contains("1200 rpm"), "{text}"); assert!(text.contains("1000ms"), "{text}"); } } } pub use cost::*; pub use gains::*; pub use preset::*; pub use relay::*; pub use search::*; pub use zn::*; #[cfg(test)] /// Test-only offline evaluator for search regression tests. struct TestSimEvaluator { ensemble: sim_harness::PlantEnsemble, scenario: experiment::Scenario, limits: sim_harness::LoopLimits, objective: cost::Objective, robust_weight: f64, } #[cfg(test)] impl TestSimEvaluator { /// Build an offline evaluator. #[must_use] pub fn new( ensemble: sim_harness::PlantEnsemble, scenario: experiment::Scenario, limits: sim_harness::LoopLimits, objective: cost::Objective, robust_weight: f64, ) -> Self { Self { ensemble, scenario, limits, objective, robust_weight } } } #[cfg(test)] impl Evaluator for TestSimEvaluator { fn evaluate(&self, gains: &motor_control::VelocityGains) -> Evaluated { let mut costs = Vec::with_capacity(self.ensemble.len()); let mut nominal = None; let mut worst_verdict = 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) => cost::Score::penalized(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(|| cost::Score::penalized(cost::Verdict::Rejected)); let verdict = if nominal.verdict.is_ok() { worst_verdict } else { nominal.verdict }; 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) } }