feat: fan control hysteresis (#292)

* feat: implement a fan spindown delay setting

* chore: change request/response logging to the trace level

* feat: add fan speed change threshold setting

* feat: implement GUI for hysteresis settings

* fix: always set hysteresis settings
This commit is contained in:
Ilya Zlobintsev 2024-03-20 23:45:24 +02:00 committed by GitHub
parent 16c50970f6
commit 0ad46e2d08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 310 additions and 107 deletions

View File

@ -10,8 +10,8 @@ use anyhow::{anyhow, Context};
use nix::unistd::getuid;
use schema::{
request::{ConfirmCommand, SetClocksCommand},
ClocksInfo, DeviceInfo, DeviceListEntry, DeviceStats, FanControlMode, FanCurveMap, PmfwOptions,
PowerStates, Request, Response, SystemInfo,
ClocksInfo, DeviceInfo, DeviceListEntry, DeviceStats, FanOptions, PowerStates, Request,
Response, SystemInfo,
};
use serde::Deserialize;
use std::{
@ -104,24 +104,8 @@ impl DaemonClient {
self.make_request(Request::ListDevices)
}
pub fn set_fan_control(
&self,
id: &str,
enabled: bool,
mode: Option<FanControlMode>,
static_speed: Option<f64>,
curve: Option<FanCurveMap>,
pmfw: PmfwOptions,
) -> anyhow::Result<u64> {
self.make_request(Request::SetFanControl {
id,
enabled,
mode,
static_speed,
curve,
pmfw,
})?
.inner()
pub fn set_fan_control(&self, cmd: FanOptions) -> anyhow::Result<u64> {
self.make_request(Request::SetFanControl(cmd))?.inner()
}
pub fn set_power_cap(&self, id: &str, cap: Option<f64>) -> anyhow::Result<u64> {

View File

@ -109,6 +109,8 @@ pub struct FanControlSettings {
pub temperature_key: String,
pub interval_ms: u64,
pub curve: FanCurve,
pub spindown_delay_ms: Option<u64>,
pub change_threshold: Option<u64>,
}
impl Default for FanControlSettings {
@ -119,6 +121,8 @@ impl Default for FanControlSettings {
temperature_key: "edge".to_owned(),
interval_ms: 500,
curve: FanCurve(default_fan_curve()),
spindown_delay_ms: None,
change_threshold: None,
}
}
}
@ -228,11 +232,10 @@ fn default_apply_settings_timer() -> u64 {
#[cfg(test)]
mod tests {
use lact_schema::{FanControlMode, PmfwOptions};
use std::collections::HashMap;
use super::{ClocksConfiguration, Config, Daemon, FanControlSettings, Gpu};
use crate::server::gpu_controller::fan_control::FanCurve;
use lact_schema::{FanControlMode, PmfwOptions};
use std::collections::HashMap;
#[test]
fn serde_de_full() {
@ -248,6 +251,8 @@ mod tests {
interval_ms: 500,
mode: FanControlMode::Curve,
static_speed: 0.5,
spindown_delay_ms: Some(5000),
change_threshold: Some(3),
}),
..Default::default()
},

View File

@ -2,7 +2,7 @@ pub mod fan_control;
use self::fan_control::FanCurve;
use super::vulkan::get_vulkan_info;
use crate::config::{self, ClocksConfiguration};
use crate::config::{self, ClocksConfiguration, FanControlSettings};
use amdgpu_sysfs::{
error::Error,
gpu_handle::{
@ -19,7 +19,6 @@ use lact_schema::{
PciInfo, PmfwInfo, PowerState, PowerStates, PowerStats, VoltageStats, VramStats,
};
use pciid_parser::Database;
use std::collections::BTreeMap;
use std::{
borrow::Cow,
cell::RefCell,
@ -29,6 +28,7 @@ use std::{
str::FromStr,
time::Duration,
};
use std::{collections::BTreeMap, time::Instant};
use tokio::{
select,
sync::Notify,
@ -250,20 +250,17 @@ impl GpuController {
}
pub fn get_stats(&self, gpu_config: Option<&config::Gpu>) -> DeviceStats {
let fan_settings = gpu_config.and_then(|config| config.fan_control_settings.as_ref());
DeviceStats {
fan: FanStats {
control_enabled: gpu_config
.map(|config| config.fan_control_enabled)
.unwrap_or_default(),
control_mode: gpu_config
.and_then(|config| config.fan_control_settings.as_ref())
.map(|settings| settings.mode),
static_speed: gpu_config
.and_then(|config| config.fan_control_settings.as_ref())
.map(|settings| settings.static_speed),
curve: gpu_config
.and_then(|config| config.fan_control_settings.as_ref())
.map(|settings| settings.curve.0.clone()),
control_mode: fan_settings.map(|settings| settings.mode),
static_speed: fan_settings.map(|settings| settings.static_speed),
curve: fan_settings.map(|settings| settings.curve.0.clone()),
spindown_delay_ms: fan_settings.and_then(|settings| settings.spindown_delay_ms),
change_threshold: fan_settings.and_then(|settings| settings.change_threshold),
speed_current: self.hw_mon_and_then(HwMon::get_fan_current),
speed_max: self.hw_mon_and_then(HwMon::get_fan_max),
speed_min: self.hw_mon_and_then(HwMon::get_fan_min),
@ -423,8 +420,7 @@ impl GpuController {
async fn start_curve_fan_control(
&self,
curve: FanCurve,
temp_key: String,
interval: Duration,
settings: FanControlSettings,
) -> anyhow::Result<()> {
// Use the PMFW curve functionality when it is available
// Otherwise, fall back to manual fan control via a task
@ -441,18 +437,14 @@ impl GpuController {
Ok(())
}
Err(_) => {
self.start_curve_fan_control_task(curve, temp_key, interval)
.await
}
Err(_) => self.start_curve_fan_control_task(curve, settings).await,
}
}
async fn start_curve_fan_control_task(
&self,
curve: FanCurve,
temp_key: String,
interval: Duration,
settings: FanControlSettings,
) -> anyhow::Result<()> {
// Stop existing task to re-apply new curve
self.stop_fan_control(false).await?;
@ -475,7 +467,17 @@ impl GpuController {
let notify = Rc::new(Notify::new());
let task_notify = notify.clone();
debug!("spawning new fan control task");
let handle = tokio::task::spawn_local(async move {
let mut last_pwm = (None, Instant::now());
let mut last_temp = 0.0;
let temp_key = settings.temperature_key.clone();
let interval = Duration::from_millis(settings.interval_ms);
let spindown_delay = Duration::from_millis(settings.spindown_delay_ms.unwrap_or(0));
#[allow(clippy::cast_precision_loss)]
let change_threshold = settings.change_threshold.unwrap_or(0) as f32;
loop {
select! {
() = sleep(interval) => (),
@ -486,7 +488,31 @@ impl GpuController {
let temp = temps
.remove(&temp_key)
.expect("Could not get temperature by given key");
let current_temp = temp.current.expect("Missing temp");
if (last_temp - current_temp).abs() < change_threshold {
trace!("temperature changed from {last_temp}°C to {current_temp}°C, which is less than the {change_threshold}°C threshold, skipping speed adjustment");
continue;
}
let target_pwm = curve.pwm_at_temp(temp);
let now = Instant::now();
if let (Some(previous_pwm), previous_timestamp) = last_pwm {
let diff = now - previous_timestamp;
if target_pwm < previous_pwm && diff < spindown_delay {
trace!(
"delaying fan spindown ({}ms left)",
(spindown_delay - diff).as_millis()
);
continue;
}
}
last_pwm = (Some(target_pwm), now);
last_temp = current_temp;
trace!("fan control tick: setting pwm to {target_pwm}");
if let Err(err) = hw_mon.set_fan_pwm(target_pwm) {
@ -501,7 +527,7 @@ impl GpuController {
debug!(
"started fan control with interval {}ms",
interval.as_millis()
settings.interval_ms
);
Ok(())
@ -742,14 +768,9 @@ impl GpuController {
return Err(anyhow!("Cannot use empty fan curve"));
}
let interval = Duration::from_millis(settings.interval_ms);
self.start_curve_fan_control(
settings.curve.clone(),
settings.temperature_key.clone(),
interval,
)
.await
.context("Failed to set curve fan control")?;
self.start_curve_fan_control(settings.curve.clone(), settings.clone())
.await
.context("Failed to set curve fan control")?;
}
}
} else {

View File

@ -11,7 +11,7 @@ use anyhow::{anyhow, Context};
use lact_schema::{
default_fan_curve,
request::{ConfirmCommand, SetClocksCommand},
ClocksInfo, DeviceInfo, DeviceListEntry, DeviceStats, FanControlMode, FanCurveMap, PmfwOptions,
ClocksInfo, DeviceInfo, DeviceListEntry, DeviceStats, FanControlMode, FanOptions, PmfwOptions,
PowerStates,
};
use libflate::gzip;
@ -270,40 +270,35 @@ impl<'a> Handler {
self.controller_by_id(id)?.get_clocks_info()
}
pub async fn set_fan_control(
&'a self,
id: &str,
enabled: bool,
mode: Option<FanControlMode>,
static_speed: Option<f64>,
curve: Option<FanCurveMap>,
pmfw: PmfwOptions,
) -> anyhow::Result<u64> {
pub async fn set_fan_control(&'a self, opts: FanOptions<'_>) -> anyhow::Result<u64> {
let settings = {
let mut config_guard = self
.config
.try_borrow_mut()
.map_err(|err| anyhow!("{err}"))?;
let gpu_config = config_guard.gpus.entry(id.to_owned()).or_default();
let gpu_config = config_guard.gpus.entry(opts.id.to_owned()).or_default();
match mode {
match opts.mode {
Some(mode) => match mode {
FanControlMode::Static => {
if matches!(static_speed, Some(speed) if !(0.0..=1.0).contains(&speed)) {
if matches!(opts.static_speed, Some(speed) if !(0.0..=1.0).contains(&speed))
{
return Err(anyhow!("static speed value out of range"));
}
if let Some(mut existing_settings) = gpu_config.fan_control_settings.clone()
{
existing_settings.mode = mode;
if let Some(static_speed) = static_speed {
if let Some(static_speed) = opts.static_speed {
existing_settings.static_speed = static_speed;
}
Some(existing_settings)
} else {
Some(FanControlSettings {
mode,
static_speed: static_speed.unwrap_or_else(default_fan_static_speed),
static_speed: opts
.static_speed
.unwrap_or_else(default_fan_static_speed),
..Default::default()
})
}
@ -312,18 +307,27 @@ impl<'a> Handler {
if let Some(mut existing_settings) = gpu_config.fan_control_settings.clone()
{
existing_settings.mode = mode;
if let Some(raw_curve) = curve {
if let Some(change_threshold) = opts.change_threshold {
existing_settings.change_threshold = Some(change_threshold);
}
if let Some(spindown_delay) = opts.spindown_delay_ms {
existing_settings.spindown_delay_ms = Some(spindown_delay);
}
if let Some(raw_curve) = opts.curve {
let curve = FanCurve(raw_curve);
curve.validate()?;
existing_settings.curve = curve;
}
Some(existing_settings)
} else {
let curve = FanCurve(curve.unwrap_or_else(default_fan_curve));
let curve = FanCurve(opts.curve.unwrap_or_else(default_fan_curve));
curve.validate()?;
Some(FanControlSettings {
mode,
curve,
change_threshold: opts.change_threshold,
spindown_delay_ms: opts.spindown_delay_ms,
..Default::default()
})
}
@ -333,12 +337,12 @@ impl<'a> Handler {
}
};
self.edit_gpu_config(id.to_owned(), |config| {
config.fan_control_enabled = enabled;
self.edit_gpu_config(opts.id.to_owned(), |config| {
config.fan_control_enabled = opts.enabled;
if let Some(settings) = settings {
config.fan_control_settings = Some(settings);
}
config.pmfw_options = pmfw;
config.pmfw_options = opts.pmfw;
})
.await
.context("Failed to edit GPU config")

View File

@ -12,7 +12,7 @@ use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
net::{UnixListener, UnixStream},
};
use tracing::{debug, error, instrument};
use tracing::{error, instrument, trace};
pub struct Server {
pub handler: Handler,
@ -52,7 +52,7 @@ pub async fn handle_stream(stream: UnixStream, handler: Handler) -> anyhow::Resu
let mut buf = String::new();
while stream.read_line(&mut buf).await? != 0 {
debug!("handling request: {}", buf.trim_end());
trace!("handling request: {}", buf.trim_end());
let maybe_request = serde_json::from_str(&buf);
let response = match maybe_request {
@ -86,18 +86,7 @@ async fn handle_request<'a>(request: Request<'a>, handler: &'a Handler) -> anyho
Request::DevicePowerProfileModes { id } => {
ok_response(handler.get_power_profile_modes(id)?)
}
Request::SetFanControl {
id,
enabled,
mode,
static_speed,
curve,
pmfw,
} => ok_response(
handler
.set_fan_control(id, enabled, mode, static_speed, curve, pmfw)
.await?,
),
Request::SetFanControl(opts) => ok_response(handler.set_fan_control(opts).await?),
Request::ResetPmfw { id } => ok_response(handler.reset_pmfw(id).await?),
Request::SetPowerCap { id, cap } => ok_response(handler.set_power_cap(id, cap).await?),
Request::SetPerformanceLevel {
@ -127,7 +116,7 @@ async fn handle_request<'a>(request: Request<'a>, handler: &'a Handler) -> anyho
}
fn ok_response<T: Serialize + Debug>(data: T) -> anyhow::Result<Vec<u8>> {
debug!("responding with {data:?}");
trace!("responding with {data:?}");
Ok(serde_json::to_vec(&Response::Ok(data))?)
}

View File

@ -13,7 +13,7 @@ use gtk::glib::{timeout_future, ControlFlow};
use gtk::{gio::ApplicationFlags, prelude::*, *};
use header::Header;
use lact_client::schema::request::{ConfirmCommand, SetClocksCommand};
use lact_client::schema::GIT_COMMIT;
use lact_client::schema::{FanOptions, GIT_COMMIT};
use lact_client::DaemonClient;
use lact_daemon::MODULE_CONF_PATH;
use root_stack::RootStack;
@ -408,16 +408,19 @@ impl App {
if let Some(thermals_settings) = self.root_stack.thermals_page.get_thermals_settings() {
debug!("applying thermal settings: {thermals_settings:?}");
let opts = FanOptions {
id: &gpu_id,
enabled: thermals_settings.manual_fan_control,
mode: thermals_settings.mode,
static_speed: thermals_settings.static_speed,
curve: thermals_settings.curve,
pmfw: thermals_settings.pmfw,
spindown_delay_ms: thermals_settings.spindown_delay_ms,
change_threshold: thermals_settings.change_threshold,
};
self.daemon_client
.set_fan_control(
&gpu_id,
thermals_settings.manual_fan_control,
thermals_settings.mode,
thermals_settings.static_speed,
thermals_settings.curve,
thermals_settings.pmfw,
)
.set_fan_control(opts)
.context("Could not set fan control")?;
self.daemon_client
.confirm_pending_config(ConfirmCommand::Confirm)

View File

@ -1,6 +1,7 @@
mod point_adjustment;
use self::point_adjustment::PointAdjustment;
use crate::app::root_stack::oc_adjustment::OcAdjustment;
use glib::clone;
use gtk::graphene::Point;
use gtk::gsk::Transform;
@ -11,11 +12,17 @@ use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
const DEFAULT_CHANGE_THRESHOLD: u64 = 2;
const DEFAULT_SPINDOWN_DELAY_MS: u64 = 5000;
#[derive(Clone)]
pub struct FanCurveFrame {
pub container: Box,
curve_container: Frame,
points: Rc<RefCell<Vec<PointAdjustment>>>,
spindown_delay_adj: OcAdjustment,
change_threshold_adj: OcAdjustment,
hysteresis_grid: Grid,
}
impl FanCurveFrame {
@ -68,15 +75,55 @@ impl FanCurveFrame {
let points = Rc::new(RefCell::new(Vec::new()));
let hysteresis_grid = Grid::new();
hysteresis_grid.set_margin_top(10);
let spindown_delay_adj = oc_adjustment_row(
&hysteresis_grid,
0,
"Spindown delay",
"How long the GPU needs to remain at a lower temperature point for before ramping down the fan",
" ms",
OcAdjustmentOptions {
default: DEFAULT_SPINDOWN_DELAY_MS as f64,
min: 0.0,
max: 30_000.0,
step: 10.0,
digits: 0,
},
);
let change_threshold_adj = oc_adjustment_row(
&hysteresis_grid,
1,
"Speed change threshold",
"Hysteresis",
"°C",
OcAdjustmentOptions {
default: DEFAULT_CHANGE_THRESHOLD as f64,
min: 0.0,
max: 10.0,
step: 1.0,
digits: 0,
},
);
root_box.append(&hysteresis_grid);
let curve_frame = Self {
container: root_box,
curve_container,
points,
spindown_delay_adj: spindown_delay_adj.clone(),
change_threshold_adj: change_threshold_adj.clone(),
hysteresis_grid,
};
default_button.connect_clicked(clone!(@strong curve_frame => move |_| {
let curve = default_fan_curve();
curve_frame.set_curve(&curve);
spindown_delay_adj.set_value(DEFAULT_SPINDOWN_DELAY_MS as f64);
change_threshold_adj.set_value(DEFAULT_CHANGE_THRESHOLD as f64);
}));
add_button.connect_clicked(clone!(@strong curve_frame => move |_| {
@ -148,6 +195,15 @@ impl FanCurveFrame {
}
pub fn connect_adjusted<F: Fn() + 'static + Clone>(&self, f: F) {
self.change_threshold_adj
.connect_value_changed(clone!(@strong f => move |_| {
f();
}));
self.spindown_delay_adj
.connect_value_changed(clone!(@strong f => move |_| {
f();
}));
let closure = clone!(@strong f => move |_: &Adjustment| {
f();
});
@ -157,6 +213,97 @@ impl FanCurveFrame {
point.temperature.connect_value_changed(closure.clone());
}
}
pub fn set_change_threshold(&self, value: Option<u64>) {
self.change_threshold_adj
.set_initial_value(value.unwrap_or(0) as f64);
}
pub fn set_spindown_delay_ms(&self, value: Option<u64>) {
self.spindown_delay_adj
.set_initial_value(value.unwrap_or(0) as f64);
}
pub fn get_change_threshold(&self) -> u64 {
self.change_threshold_adj.value() as u64
}
pub fn get_spindown_delay_ms(&self) -> u64 {
self.spindown_delay_adj.value() as u64
}
pub fn set_hysteresis_settings_visibile(&self, visible: bool) {
self.hysteresis_grid.set_visible(visible);
}
}
struct OcAdjustmentOptions {
default: f64,
min: f64,
max: f64,
step: f64,
digits: i32,
}
fn oc_adjustment_row(
grid: &Grid,
row: i32,
label: &str,
tooltip: &str,
unit: &'static str,
opts: OcAdjustmentOptions,
) -> OcAdjustment {
let label = Label::builder()
.label(label)
.halign(Align::Start)
.tooltip_text(tooltip)
.build();
let adjustment = OcAdjustment::new(
opts.default,
opts.min,
opts.max,
opts.step,
opts.step,
opts.step,
);
let scale = Scale::builder()
.orientation(Orientation::Horizontal)
.adjustment(&adjustment)
.hexpand(true)
.round_digits(opts.digits)
.digits(opts.digits)
.value_pos(PositionType::Right)
.margin_start(5)
.margin_end(5)
.build();
let value_selector = SpinButton::new(Some(&adjustment), opts.step, opts.digits as u32);
let value_label = Label::new(Some(&format!("{}{unit}", opts.default)));
let popover = Popover::builder().child(&value_selector).build();
let value_button = MenuButton::builder()
.popover(&popover)
.child(&value_label)
.build();
adjustment.connect_value_changed(clone!(@strong value_label => move |adjustment| {
let value = match opts.digits {
0 => adjustment.value().round(),
_ => {
let rounding = opts.digits as f64 * 10.0;
(adjustment.value() * rounding).round() / rounding
}
};
value_label.set_text(&format!("{value}{unit}"));
}));
grid.attach(&label, 0, row, 1, 1);
grid.attach(&scale, 1, row, 4, 1);
grid.attach(&value_button, 6, row, 4, 1);
adjustment
}
#[cfg(all(test, feature = "gtk-tests"))]

View File

@ -5,7 +5,7 @@ use glib::clone;
use gtk::prelude::*;
use gtk::*;
use lact_client::schema::{
default_fan_curve, DeviceInfo, DeviceStats, FanControlMode, FanCurveMap, PmfwOptions,
default_fan_curve, DeviceInfo, DeviceStats, FanControlMode, FanCurveMap, PmfwInfo, PmfwOptions,
SystemInfo,
};
use lact_daemon::AMDGPU_FAMILY_GC_11_0_0;
@ -25,6 +25,8 @@ pub struct ThermalsSettings {
pub static_speed: Option<f64>,
pub curve: Option<FanCurveMap>,
pub pmfw: PmfwOptions,
pub spindown_delay_ms: Option<u64>,
pub change_threshold: Option<u64>,
}
#[derive(Clone)]
@ -195,6 +197,15 @@ impl ThermalsPage {
self.fan_curve_frame.set_curve(curve);
}
self.fan_curve_frame
.set_spindown_delay_ms(stats.fan.spindown_delay_ms);
self.fan_curve_frame
.set_change_threshold(stats.fan.change_threshold);
// Only show hysteresis settings when PMFW is not used
self.fan_curve_frame
.set_hysteresis_settings_visibile(stats.fan.pmfw_info == PmfwInfo::default());
if !stats.fan.control_enabled && self.fan_curve_frame.get_curve().is_empty() {
self.fan_curve_frame.set_curve(&default_fan_curve());
}
@ -246,6 +257,8 @@ impl ThermalsPage {
static_speed,
curve,
pmfw,
change_threshold: Some(self.fan_curve_frame.get_change_threshold()),
spindown_delay_ms: Some(self.fan_curve_frame.get_spindown_delay_ms()),
})
} else {
None

View File

@ -197,6 +197,8 @@ pub struct FanStats {
pub speed_current: Option<u32>,
pub speed_max: Option<u32>,
pub speed_min: Option<u32>,
pub spindown_delay_ms: Option<u64>,
pub change_threshold: Option<u64>,
// RDNA3+ params
#[serde(default)]
pub pmfw_info: PmfwInfo,
@ -273,3 +275,17 @@ pub struct PmfwOptions {
pub minimum_pwm: Option<u32>,
pub target_temperature: Option<u32>,
}
#[skip_serializing_none]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
pub struct FanOptions<'a> {
pub id: &'a str,
pub enabled: bool,
pub mode: Option<FanControlMode>,
pub static_speed: Option<f64>,
pub curve: Option<FanCurveMap>,
#[serde(default)]
pub pmfw: PmfwOptions,
pub spindown_delay_ms: Option<u64>,
pub change_threshold: Option<u64>,
}

View File

@ -1,4 +1,4 @@
use crate::{FanControlMode, FanCurveMap, PmfwOptions};
use crate::FanOptions;
use amdgpu_sysfs::gpu_handle::{PerformanceLevel, PowerLevelKind};
use serde::{Deserialize, Serialize};
@ -20,15 +20,7 @@ pub enum Request<'a> {
DevicePowerProfileModes {
id: &'a str,
},
SetFanControl {
id: &'a str,
enabled: bool,
mode: Option<FanControlMode>,
static_speed: Option<f64>,
curve: Option<FanCurveMap>,
#[serde(default)]
pmfw: PmfwOptions,
},
SetFanControl(FanOptions<'a>),
ResetPmfw {
id: &'a str,
},

View File

@ -1,6 +1,7 @@
use crate::{Pong, Request, Response};
use crate::{FanControlMode, FanOptions, PmfwOptions, Pong, Request, Response};
use anyhow::anyhow;
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn ping_requset() {
@ -57,3 +58,31 @@ fn error_response() {
assert_eq!(serde_json::to_value(response).unwrap(), expected_response);
}
#[test]
fn set_fan_clocks() {
let value = r#"{
"command": "set_fan_control",
"args": {
"id": "123",
"enabled": true,
"mode": "curve",
"curve": {
"30": 30.0,
"50": 50.0
}
}
}"#;
let request: Request = serde_json::from_str(value).unwrap();
let expected_request = Request::SetFanControl(FanOptions {
id: "123",
enabled: true,
mode: Some(FanControlMode::Curve),
static_speed: None,
curve: Some(BTreeMap::from([(30, 30.0), (50, 50.0)])),
pmfw: PmfwOptions::default(),
spindown_delay_ms: None,
change_threshold: None,
});
assert_eq!(expected_request, request);
}