3539 lines
132 KiB
Rust
3539 lines
132 KiB
Rust
#![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)
|
||
}
|
||
}
|