Files
linux_sentry_chassis/crates/tuning/src/lib.rs
T

3539 lines
132 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![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<f32>,
/// 本相内观测到的极值。
extreme: f32,
/// 最近一次 High 相的峰值。
peak: Option<f32>,
/// 最近一次 Low 相的谷值。
trough: Option<f32>,
/// 已记录的振荡点。
points: Vec<OscillationPoint>,
/// 用于 `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::<f32>() / count;
let mean_period = window.iter().map(|point| point.period_s).sum::<f32>() / 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<TuneResult> {
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<VelocityGains> {
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<Self> {
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<StepMetrics>,
}
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<Self, GainsError> {
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<VelocityGains> {
let count = count.max(1);
let mut columns: Vec<Vec<f64>> = 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<String> {
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::<Vec<_>>().join("; ")
}
}
/// 解析 `MIN..MAX`(`..=` 视为等价)。
///
/// # Errors
///
/// 无法解析或区间非法时返回错误。
pub fn parse_axis(
name: &'static str,
spec: &str,
scale: Scale,
) -> Result<GainAxis, GainsError> {
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<f64> = 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<ScaleSpec> 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<ProfileSpec> 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<SolverSpec> 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<T>`:`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<ScaleSpec>,
}
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<RangeSpec>,
/// 积分增益区间。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ki: Option<RangeSpec>,
/// 微分增益区间。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kd: Option<RangeSpec>,
/// 速度前馈区间。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub feedforward: Option<RangeSpec>,
}
/// 覆盖单条轴:区间整体替换,刻度继承。
fn overlay_range(base: Option<&RangeSpec>, other: Option<&RangeSpec>) -> Option<RangeSpec> {
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<String>,
/// 评估后端。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend: Option<BackendSpec>,
/// 增益参数空间。
#[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<Tuning, PresetError> {
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<GainSpace, PresetError> {
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<Scenario, PresetError> {
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<Weights, PresetError> {
let spec = &self.cost;
let value = |field: &'static str, v: Option<f64>| -> Result<f64, PresetError> {
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<Acceptance, PresetError> {
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<SearchConfig, PresetError> {
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<PlantTolerances, PresetError> {
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<usize, PresetError> {
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<f64, PresetError> {
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<BenchEnvelope, PresetError> {
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<GainAxis, PresetError> {
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<String>,
/// 具名预设。
#[serde(default)]
pub preset: BTreeMap<String, Preset>,
}
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<Self, PresetError> {
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<Self, PresetError> {
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<String> {
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<String>,
},
/// 多个预设但没有默认名。
#[error("预设文件有多个预设但未指定 default_preset(请用 --preset-name 指定,可选:{})", available.join(", "))]
NoDefault {
/// 可选名字。
available: Vec<String>,
},
/// 缺少必需项。
#[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<Tuning, PresetError> {
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<T> {
value: T,
}
let parse = |text: &str| -> Wrapper<ScaleSpec> {
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<ProfileSpec> =
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<Self, SearchError> {
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<Trial>,
/// 最优候选。
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<u64> {
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<Option<(f64, [f64; AXIS_COUNT])>>,
}
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<f64>;
type Output = f64;
fn cost(&self, param: &Self::Param) -> Result<f64, ArgminError> {
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<f64>> = 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<Vec<f64>, 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<Vec<f64>, 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<Ranked, SearchError> {
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<Trial> = 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<Trial> = trials.iter().take(config.top_k.max(1)).copied().collect();
// ---------------- 阶段 2:多起点精修(并行) ----------------
let coarse_count = trials.len() as u64;
let refined: Vec<Trial> = 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::<f64>() / 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)
}
}