use arika::epoch::Epoch;
use nalgebra::Vector3;
use super::ExternalLoads;
use crate::effector::StateEffector;
use crate::model::HasAttitude;
#[derive(Debug, Clone)]
pub struct Rw {
axis: Vector3<f64>,
pub inertia: f64,
pub max_momentum: f64,
pub max_torque: f64,
pub max_speed: f64,
pub motor_time_constant: Option<f64>,
}
impl Rw {
pub fn new(axis: Vector3<f64>, inertia: f64, max_momentum: f64, max_torque: f64) -> Self {
let norm = axis.magnitude();
assert!(norm > 1e-15, "Wheel axis must be non-zero");
assert!(
inertia.is_finite() && inertia > 0.0,
"inertia must be positive and finite, got {inertia}"
);
assert!(
max_momentum >= 0.0 && max_momentum.is_finite(),
"max_momentum must be non-negative and finite, got {max_momentum}"
);
assert!(
max_torque >= 0.0 && max_torque.is_finite(),
"max_torque must be non-negative and finite, got {max_torque}"
);
let max_speed = max_momentum / inertia;
Self {
axis: axis / norm,
inertia,
max_momentum,
max_torque,
max_speed,
motor_time_constant: None,
}
}
pub fn with_max_speed(
axis: Vector3<f64>,
inertia: f64,
max_momentum: f64,
max_torque: f64,
max_speed: f64,
) -> Self {
assert!(
max_speed >= 0.0 && max_speed.is_finite(),
"max_speed must be non-negative and finite, got {max_speed}"
);
let mut rw = Self::new(axis, inertia, max_momentum, max_torque);
let derived_max_speed = max_momentum / inertia;
rw.max_speed = max_speed.min(derived_max_speed);
rw.max_momentum = rw.max_momentum.min(inertia * rw.max_speed);
rw
}
pub fn with_motor_lag(mut self, t_m: f64) -> Self {
assert!(
t_m > 0.0 && t_m.is_finite(),
"motor_time_constant must be positive and finite, got {t_m}"
);
self.motor_time_constant = Some(t_m);
self
}
pub fn axis(&self) -> &Vector3<f64> {
&self.axis
}
pub fn speed_from_momentum(&self, h: f64) -> f64 {
h / self.inertia
}
}
pub(super) fn build_allocation_pinv(axes: &[Vector3<f64>]) -> nalgebra::DMatrix<f64> {
use nalgebra::DMatrix;
let n = axes.len();
if n == 0 {
return DMatrix::zeros(0, 3);
}
let b = DMatrix::from_fn(3, n, |row, col| axes[col][row]);
let eps = 1e-12;
b.pseudo_inverse(eps)
.expect("pseudo_inverse with eps=1e-12 should not fail for unit axes")
}
#[derive(Debug, Clone)]
pub struct RwAssemblyCore {
wheels: Vec<Rw>,
alloc_pinv: nalgebra::DMatrix<f64>,
has_motor_lag: bool,
}
impl RwAssemblyCore {
pub fn new(wheels: Vec<Rw>) -> Self {
let axes: Vec<_> = wheels.iter().map(|w| *w.axis()).collect();
let alloc_pinv = build_allocation_pinv(&axes);
let has_motor_lag = wheels.iter().any(|w| w.motor_time_constant.is_some());
Self {
wheels,
alloc_pinv,
has_motor_lag,
}
}
pub fn three_axis(inertia: f64, max_momentum: f64, max_torque: f64) -> Self {
Self::new(vec![
Rw::new(Vector3::x(), inertia, max_momentum, max_torque),
Rw::new(Vector3::y(), inertia, max_momentum, max_torque),
Rw::new(Vector3::z(), inertia, max_momentum, max_torque),
])
}
pub fn wheels(&self) -> &[Rw] {
&self.wheels
}
pub fn num_wheels(&self) -> usize {
self.wheels.len()
}
pub fn has_motor_lag(&self) -> bool {
self.has_motor_lag
}
pub fn state_dim(&self) -> usize {
let n = self.wheels.len();
if self.has_motor_lag { 2 * n } else { n }
}
pub fn momentum_slice<'a>(&self, aux: &'a [f64]) -> &'a [f64] {
&aux[..self.wheels.len()]
}
pub fn realized_torque_slice<'a>(&self, aux: &'a [f64]) -> Option<&'a [f64]> {
if self.has_motor_lag {
let n = self.wheels.len();
Some(&aux[n..2 * n])
} else {
None
}
}
pub fn clamp_torques(&self, commanded: &[f64], momentum: &[f64]) -> Vec<f64> {
let accepted = self.clamp_command(commanded);
self.clamp_physical(&accepted, momentum)
}
pub fn clamp_command(&self, commanded: &[f64]) -> Vec<f64> {
assert_eq!(commanded.len(), self.wheels.len());
self.wheels
.iter()
.enumerate()
.map(|(i, wheel)| commanded[i].clamp(-wheel.max_torque, wheel.max_torque))
.collect()
}
pub fn clamp_physical(&self, torques: &[f64], momentum: &[f64]) -> Vec<f64> {
assert_eq!(torques.len(), self.wheels.len());
assert_eq!(momentum.len(), self.wheels.len());
self.wheels
.iter()
.enumerate()
.map(|(i, wheel)| {
let mut tau = torques[i];
if (momentum[i] >= wheel.max_momentum && tau > 0.0)
|| (momentum[i] <= -wheel.max_momentum && tau < 0.0)
{
tau = 0.0;
}
let speed = wheel.speed_from_momentum(momentum[i]);
if (speed >= wheel.max_speed && tau > 0.0)
|| (speed <= -wheel.max_speed && tau < 0.0)
{
tau = 0.0;
}
tau
})
.collect()
}
pub fn speed_to_torque(&self, target_speeds: &[f64], momentum: &[f64], gain: f64) -> Vec<f64> {
assert_eq!(target_speeds.len(), self.wheels.len());
assert_eq!(momentum.len(), self.wheels.len());
self.wheels
.iter()
.enumerate()
.map(|(i, wheel)| {
let target = target_speeds[i].clamp(-wheel.max_speed, wheel.max_speed);
let current = wheel.speed_from_momentum(momentum[i]);
gain * (target - current)
})
.collect()
}
pub fn reaction_torque(&self, clamped: &[f64]) -> Vector3<f64> {
let mut total = Vector3::zeros();
for (wheel, &tau) in self.wheels.iter().zip(clamped.iter()) {
total -= tau * wheel.axis;
}
total
}
pub fn gyroscopic_torque(&self, omega: &Vector3<f64>, momentum: &[f64]) -> Vector3<f64> {
let mut h_rw = Vector3::zeros();
for (wheel, &h) in self.wheels.iter().zip(momentum.iter()) {
h_rw += h * wheel.axis;
}
-omega.cross(&h_rw)
}
pub fn allocate(&self, desired: &Vector3<f64>) -> Vec<f64> {
let neg_desired =
nalgebra::DVector::from_column_slice(&[-desired.x, -desired.y, -desired.z]);
let result = &self.alloc_pinv * neg_desired;
result.as_slice().to_vec()
}
}
pub use crate::plugin::command::RwCommand;
const DEFAULT_SPEED_CONTROL_BANDWIDTH: f64 = 10.0;
#[derive(Clone)]
pub struct RwAssembly {
core: RwAssemblyCore,
pub command: RwCommand,
pub speed_control_gain: f64,
}
impl RwAssembly {
pub fn new(wheels: Vec<Rw>) -> Self {
let n = wheels.len();
let gain = wheels
.first()
.map(|w| w.inertia * DEFAULT_SPEED_CONTROL_BANDWIDTH)
.unwrap_or(0.1);
Self {
core: RwAssemblyCore::new(wheels),
command: RwCommand::Torques(vec![0.0; n]),
speed_control_gain: gain,
}
}
pub fn three_axis(inertia: f64, max_momentum: f64, max_torque: f64) -> Self {
let core = RwAssemblyCore::three_axis(inertia, max_momentum, max_torque);
let n = core.num_wheels();
Self {
core,
command: RwCommand::Torques(vec![0.0; n]),
speed_control_gain: inertia * DEFAULT_SPEED_CONTROL_BANDWIDTH,
}
}
pub fn core(&self) -> &RwAssemblyCore {
&self.core
}
pub fn wheels(&self) -> &[Rw] {
self.core.wheels()
}
}
impl<S: HasAttitude + Send + Sync> StateEffector<S> for RwAssembly {
fn name(&self) -> &str {
"reaction_wheels"
}
fn state_dim(&self) -> usize {
self.core.state_dim()
}
fn aux_bounds(&self) -> Vec<(f64, f64)> {
let mut bounds: Vec<_> = self
.core
.wheels
.iter()
.map(|w| (-w.max_momentum, w.max_momentum))
.collect();
if self.core.has_motor_lag() {
for w in &self.core.wheels {
bounds.push((-w.max_torque, w.max_torque));
}
}
bounds
}
fn derivatives(
&self,
_t: f64,
state: &S,
aux: &[f64],
aux_rates: &mut [f64],
_epoch: Option<&Epoch>,
) -> ExternalLoads {
let n = self.core.num_wheels();
let omega = &state.attitude().angular_velocity;
let momentum = self.core.momentum_slice(aux);
let effective_torques = match &self.command {
RwCommand::Torques(t) => t.clone(),
RwCommand::Speeds(s) => self
.core
.speed_to_torque(s, momentum, self.speed_control_gain),
};
let accepted = self.core.clamp_command(&effective_torques);
let applied = if self.core.has_motor_lag() {
let tau_realized = self.core.realized_torque_slice(aux).unwrap();
let tau_target = self.core.clamp_physical(&accepted, momentum);
for (i, wheel) in self.core.wheels.iter().enumerate() {
if let Some(t_m) = wheel.motor_time_constant {
aux_rates[n + i] = (tau_target[i] - tau_realized[i]) / t_m;
} else {
aux_rates[n + i] = (tau_target[i] - tau_realized[i]) * 100.0;
}
}
let effective_realized: Vec<f64> = self
.core
.wheels
.iter()
.enumerate()
.map(|(i, w)| {
if w.motor_time_constant.is_some() {
tau_realized[i]
} else {
tau_target[i]
}
})
.collect();
self.core.clamp_physical(&effective_realized, momentum)
} else {
self.core.clamp_physical(&accepted, momentum)
};
for (i, &tau) in applied.iter().enumerate() {
aux_rates[i] = tau;
}
let reaction = self.core.reaction_torque(&applied);
let gyro = self.core.gyroscopic_torque(omega, momentum);
ExternalLoads::torque(reaction + gyro)
}
}
pub type ReactionWheel = Rw;
pub type ReactionWheelAssembly = RwAssembly;
#[cfg(test)]
mod tests {
use super::*;
use crate::attitude::AttitudeState;
use nalgebra::Vector4;
fn test_state_at_rest() -> AttitudeState {
AttitudeState::identity()
}
#[test]
fn three_axis_has_three_wheels() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
assert_eq!(core.num_wheels(), 3);
}
#[test]
#[should_panic(expected = "inertia must be positive")]
fn rw_zero_inertia_panics() {
Rw::new(Vector3::x(), 0.0, 1.0, 0.1);
}
#[test]
#[should_panic(expected = "inertia must be positive")]
fn rw_negative_inertia_panics() {
Rw::new(Vector3::x(), -1.0, 1.0, 0.1);
}
#[test]
#[should_panic(expected = "inertia must be positive")]
fn rw_nan_inertia_panics() {
Rw::new(Vector3::x(), f64::NAN, 1.0, 0.1);
}
#[test]
fn rw_max_speed_derived() {
let rw = Rw::new(Vector3::x(), 0.01, 1.0, 0.1);
assert!((rw.max_speed - 100.0).abs() < 1e-10); }
#[test]
fn rw_max_speed_custom_lower() {
let rw = Rw::with_max_speed(Vector3::x(), 0.01, 1.0, 0.1, 50.0);
assert!((rw.max_speed - 50.0).abs() < 1e-10);
}
#[test]
fn rw_max_speed_custom_higher_clamped() {
let rw = Rw::with_max_speed(Vector3::x(), 0.01, 1.0, 0.1, 200.0);
assert!((rw.max_speed - 100.0).abs() < 1e-10); }
#[test]
fn rw_max_speed_tightens_max_momentum() {
let rw = Rw::with_max_speed(Vector3::x(), 0.01, 1.0, 0.1, 50.0);
assert!((rw.max_momentum - 0.5).abs() < 1e-10);
}
#[test]
fn clamp_command_rate_limiting() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
let accepted = core.clamp_command(&[10.0, -10.0, 0.05]);
assert!((accepted[0] - 0.1).abs() < 1e-15);
assert!((accepted[1] - (-0.1)).abs() < 1e-15);
assert!((accepted[2] - 0.05).abs() < 1e-15);
}
#[test]
fn clamp_command_ignores_momentum() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
let accepted = core.clamp_command(&[0.05, 0.0, 0.0]);
assert!((accepted[0] - 0.05).abs() < 1e-15);
}
#[test]
fn clamp_physical_saturation_positive() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
let applied = core.clamp_physical(&[0.05, 0.0, 0.0], &[1.0, 0.0, 0.0]);
assert!(applied[0].abs() < 1e-15);
}
#[test]
fn clamp_physical_allows_desaturation() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
let applied = core.clamp_physical(&[-0.05, 0.0, 0.0], &[1.0, 0.0, 0.0]);
assert!((applied[0] - (-0.05)).abs() < 1e-15);
}
#[test]
fn clamp_physical_passes_through_below_limits() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
let applied = core.clamp_physical(&[0.05, -0.03, 0.02], &[0.0, 0.0, 0.0]);
assert!((applied[0] - 0.05).abs() < 1e-15);
assert!((applied[1] - (-0.03)).abs() < 1e-15);
assert!((applied[2] - 0.02).abs() < 1e-15);
}
#[test]
fn clamp_torques_rate_limiting() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
let momentum = [0.0, 0.0, 0.0];
let clamped = core.clamp_torques(&[10.0, -10.0, 0.05], &momentum);
assert!((clamped[0] - 0.1).abs() < 1e-15);
assert!((clamped[1] - (-0.1)).abs() < 1e-15);
assert!((clamped[2] - 0.05).abs() < 1e-15);
}
#[test]
fn clamp_torques_saturation_positive() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
let clamped = core.clamp_torques(&[0.05, 0.0, 0.0], &[1.0, 0.0, 0.0]);
assert!(clamped[0].abs() < 1e-15);
}
#[test]
fn clamp_torques_saturation_allows_desaturation() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
let clamped = core.clamp_torques(&[-0.05, 0.0, 0.0], &[1.0, 0.0, 0.0]);
assert!((clamped[0] - (-0.05)).abs() < 1e-15);
}
#[test]
fn reaction_torque_opposes_wheel_acceleration() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
let clamped = [0.05, 0.03, -0.02];
let tau = core.reaction_torque(&clamped);
assert!((tau.x - (-0.05)).abs() < 1e-15);
assert!((tau.y - (-0.03)).abs() < 1e-15);
assert!((tau.z - 0.02).abs() < 1e-15);
}
#[test]
fn gyroscopic_torque() {
let core = RwAssemblyCore::three_axis(0.01, 10.0, 0.5);
let omega = Vector3::new(0.0, 0.0, 1.0);
let momentum = [5.0, 0.0, 0.0];
let tau = core.gyroscopic_torque(&omega, &momentum);
assert!(tau.x.abs() < 1e-15);
assert!((tau.y - (-5.0)).abs() < 1e-15);
assert!(tau.z.abs() < 1e-15);
}
#[test]
fn allocate_orthogonal() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
let desired = Vector3::new(0.05, 0.03, 0.02);
let allocated = core.allocate(&desired);
assert!((allocated[0] - (-0.05)).abs() < 1e-12);
assert!((allocated[1] - (-0.03)).abs() < 1e-12);
assert!((allocated[2] - (-0.02)).abs() < 1e-12);
}
#[test]
fn allocate_orthogonal_roundtrip() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
let desired = Vector3::new(0.05, 0.03, 0.02);
let allocated = core.allocate(&desired);
let realized = core.reaction_torque(&allocated);
assert!(
(realized - desired).magnitude() < 1e-12,
"roundtrip error: {:.3e}",
(realized - desired).magnitude()
);
}
#[test]
fn allocate_pyramid_4wheel_roundtrip() {
let angle = std::f64::consts::FRAC_PI_4; let sin = angle.sin();
let cos = angle.cos();
let core = RwAssemblyCore::new(vec![
Rw::new(Vector3::new(sin, 0.0, cos), 0.01, 1.0, 0.1),
Rw::new(Vector3::new(0.0, sin, cos), 0.01, 1.0, 0.1),
Rw::new(Vector3::new(-sin, 0.0, cos), 0.01, 1.0, 0.1),
Rw::new(Vector3::new(0.0, -sin, cos), 0.01, 1.0, 0.1),
]);
let desired = Vector3::new(0.05, 0.03, 0.02);
let allocated = core.allocate(&desired);
assert_eq!(allocated.len(), 4);
let realized = core.reaction_torque(&allocated);
assert!(
(realized - desired).magnitude() < 1e-12,
"pyramid roundtrip error: {:.3e}",
(realized - desired).magnitude()
);
}
#[test]
fn allocate_2axis_drops_unrealizable() {
let core = RwAssemblyCore::new(vec![
Rw::new(Vector3::x(), 0.01, 1.0, 0.1),
Rw::new(Vector3::y(), 0.01, 1.0, 0.1),
]);
let desired = Vector3::new(0.05, 0.03, 0.02);
let allocated = core.allocate(&desired);
assert_eq!(allocated.len(), 2);
let realized = core.reaction_torque(&allocated);
assert!((realized.x - 0.05).abs() < 1e-12);
assert!((realized.y - 0.03).abs() < 1e-12);
assert!(realized.z.abs() < 1e-12);
}
#[test]
fn clamp_torques_speed_saturation() {
let core =
RwAssemblyCore::new(vec![Rw::with_max_speed(Vector3::x(), 0.01, 1.0, 0.1, 50.0)]);
let clamped = core.clamp_torques(&[0.05], &[0.5]);
assert!(clamped[0].abs() < 1e-15);
}
#[test]
fn clamp_torques_speed_saturation_allows_despin() {
let core =
RwAssemblyCore::new(vec![Rw::with_max_speed(Vector3::x(), 0.01, 1.0, 0.1, 50.0)]);
let clamped = core.clamp_torques(&[-0.05], &[0.5]);
assert!((clamped[0] - (-0.05)).abs() < 1e-15);
}
#[test]
fn speed_to_torque_basic() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
let gain = 0.5; let torques = core.speed_to_torque(&[10.0, 0.0, 0.0], &[0.0, 0.0, 0.0], gain);
assert!((torques[0] - 5.0).abs() < 1e-14);
assert!(torques[1].abs() < 1e-14);
assert!(torques[2].abs() < 1e-14);
}
#[test]
fn speed_to_torque_at_target() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
let gain = 0.5;
let torques = core.speed_to_torque(&[10.0, 0.0, 0.0], &[0.1, 0.0, 0.0], gain);
assert!(torques[0].abs() < 1e-14);
}
#[test]
fn speed_to_torque_clamps_target_to_max_speed() {
let core =
RwAssemblyCore::new(vec![Rw::with_max_speed(Vector3::x(), 0.01, 1.0, 0.1, 50.0)]);
let gain = 1.0;
let torques = core.speed_to_torque(&[100.0], &[0.0], gain);
assert!((torques[0] - 50.0).abs() < 1e-14);
}
#[test]
fn assembly_three_axis_has_three_wheels() {
let rw = RwAssembly::three_axis(0.01, 1.0, 0.1);
assert_eq!(rw.wheels().len(), 3);
assert_eq!(StateEffector::<AttitudeState>::state_dim(&rw), 3);
}
#[test]
fn name_is_reaction_wheels() {
let rw = RwAssembly::three_axis(0.01, 1.0, 0.1);
assert_eq!(StateEffector::<AttitudeState>::name(&rw), "reaction_wheels");
}
#[test]
fn zero_command_zero_output() {
let rw = RwAssembly::three_axis(0.01, 1.0, 0.1);
let state = test_state_at_rest();
let aux = vec![0.0, 0.0, 0.0];
let mut rates = vec![0.0, 0.0, 0.0];
let loads = rw.derivatives(0.0, &state, &aux, &mut rates, None);
assert_eq!(rates, vec![0.0, 0.0, 0.0]);
assert!(loads.torque_body.magnitude() < 1e-15);
}
#[test]
fn commanded_torque_z_produces_rates() {
let mut rw = RwAssembly::three_axis(0.01, 1.0, 0.1);
rw.command = RwCommand::Torques(vec![0.0, 0.0, -0.05]);
let state = test_state_at_rest();
let aux = vec![0.0, 0.0, 0.0];
let mut rates = vec![0.0, 0.0, 0.0];
let loads = rw.derivatives(0.0, &state, &aux, &mut rates, None);
assert!(rates[0].abs() < 1e-15);
assert!(rates[1].abs() < 1e-15);
assert!((rates[2] - (-0.05)).abs() < 1e-15);
assert!((loads.torque_body.z() - 0.05).abs() < 1e-15);
}
#[test]
fn torque_rate_limiting() {
let mut rw = RwAssembly::three_axis(0.01, 1.0, 0.1);
rw.command = RwCommand::Torques(vec![10.0, 0.0, 0.0]);
let state = test_state_at_rest();
let aux = vec![0.0, 0.0, 0.0];
let mut rates = vec![0.0, 0.0, 0.0];
rw.derivatives(0.0, &state, &aux, &mut rates, None);
assert!((rates[0] - 0.1).abs() < 1e-15);
}
#[test]
fn momentum_saturation_positive() {
let mut rw = RwAssembly::three_axis(0.01, 1.0, 0.1);
rw.command = RwCommand::Torques(vec![0.05, 0.0, 0.0]);
let state = test_state_at_rest();
let aux = vec![1.0, 0.0, 0.0];
let mut rates = vec![0.0, 0.0, 0.0];
rw.derivatives(0.0, &state, &aux, &mut rates, None);
assert!(rates[0].abs() < 1e-15);
}
#[test]
fn momentum_saturation_negative() {
let mut rw = RwAssembly::three_axis(0.01, 1.0, 0.1);
rw.command = RwCommand::Torques(vec![-0.05, 0.0, 0.0]);
let state = test_state_at_rest();
let aux = vec![-1.0, 0.0, 0.0];
let mut rates = vec![0.0, 0.0, 0.0];
rw.derivatives(0.0, &state, &aux, &mut rates, None);
assert!(rates[0].abs() < 1e-15);
}
#[test]
fn momentum_saturation_allows_opposite_direction() {
let mut rw = RwAssembly::three_axis(0.01, 1.0, 0.1);
rw.command = RwCommand::Torques(vec![-0.05, 0.0, 0.0]);
let state = test_state_at_rest();
let aux = vec![1.0, 0.0, 0.0];
let mut rates = vec![0.0, 0.0, 0.0];
rw.derivatives(0.0, &state, &aux, &mut rates, None);
assert!((rates[0] - (-0.05)).abs() < 1e-15);
}
#[test]
fn reaction_torque_opposes_wheel_acceleration_at_rest() {
let mut rw = RwAssembly::three_axis(0.01, 1.0, 0.1);
let desired = Vector3::new(0.05, 0.03, 0.02);
rw.command = RwCommand::Torques(rw.core().allocate(&desired));
let state = test_state_at_rest();
let aux = vec![0.0, 0.0, 0.0];
let mut rates = vec![0.0, 0.0, 0.0];
let loads = rw.derivatives(0.0, &state, &aux, &mut rates, None);
let tb = loads.torque_body.into_inner();
assert!((tb[0] - desired[0]).abs() < 1e-15);
assert!((tb[1] - desired[1]).abs() < 1e-15);
assert!((tb[2] - desired[2]).abs() < 1e-15);
}
#[test]
fn rw_with_motor_lag() {
let rw = Rw::new(Vector3::x(), 0.01, 1.0, 0.1).with_motor_lag(0.05);
assert_eq!(rw.motor_time_constant, Some(0.05));
}
#[test]
#[should_panic(expected = "motor_time_constant must be positive")]
fn rw_motor_lag_zero_panics() {
Rw::new(Vector3::x(), 0.01, 1.0, 0.1).with_motor_lag(0.0);
}
#[test]
#[should_panic(expected = "motor_time_constant must be positive")]
fn rw_motor_lag_negative_panics() {
Rw::new(Vector3::x(), 0.01, 1.0, 0.1).with_motor_lag(-1.0);
}
#[test]
fn core_has_motor_lag_false() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
assert!(!core.has_motor_lag());
assert_eq!(core.state_dim(), 3);
}
#[test]
fn core_has_motor_lag_true() {
let core = RwAssemblyCore::new(vec![
Rw::new(Vector3::x(), 0.01, 1.0, 0.1).with_motor_lag(0.05),
Rw::new(Vector3::y(), 0.01, 1.0, 0.1),
Rw::new(Vector3::z(), 0.01, 1.0, 0.1).with_motor_lag(0.1),
]);
assert!(core.has_motor_lag());
assert_eq!(core.state_dim(), 6); }
#[test]
fn momentum_slice_no_lag() {
let core = RwAssemblyCore::three_axis(0.01, 1.0, 0.1);
let aux = [1.0, 2.0, 3.0];
assert_eq!(core.momentum_slice(&aux), &[1.0, 2.0, 3.0]);
assert!(core.realized_torque_slice(&aux).is_none());
}
#[test]
fn momentum_and_torque_slices_with_lag() {
let core = RwAssemblyCore::new(vec![
Rw::new(Vector3::x(), 0.01, 1.0, 0.1).with_motor_lag(0.05),
Rw::new(Vector3::y(), 0.01, 1.0, 0.1).with_motor_lag(0.05),
]);
let aux = [0.5, -0.3, 0.01, -0.02];
assert_eq!(core.momentum_slice(&aux), &[0.5, -0.3]);
assert_eq!(core.realized_torque_slice(&aux), Some(&[0.01, -0.02][..]));
}
#[test]
fn assembly_motor_lag_state_dim() {
let rw = RwAssembly::new(vec![
Rw::new(Vector3::x(), 0.01, 1.0, 0.1).with_motor_lag(0.05),
Rw::new(Vector3::y(), 0.01, 1.0, 0.1).with_motor_lag(0.05),
Rw::new(Vector3::z(), 0.01, 1.0, 0.1).with_motor_lag(0.05),
]);
assert_eq!(StateEffector::<AttitudeState>::state_dim(&rw), 6);
}
#[test]
fn assembly_motor_lag_aux_bounds() {
let rw = RwAssembly::new(vec![
Rw::new(Vector3::x(), 0.01, 1.0, 0.1).with_motor_lag(0.05),
Rw::new(Vector3::y(), 0.01, 1.0, 0.1).with_motor_lag(0.05),
]);
let bounds = StateEffector::<AttitudeState>::aux_bounds(&rw);
assert_eq!(bounds.len(), 4); assert_eq!(bounds[0], (-1.0, 1.0));
assert_eq!(bounds[1], (-1.0, 1.0));
assert_eq!(bounds[2], (-0.1, 0.1));
assert_eq!(bounds[3], (-0.1, 0.1));
}
#[test]
fn assembly_motor_lag_derivatives_has_torque_lag() {
let mut rw = RwAssembly::new(vec![
Rw::new(Vector3::x(), 0.01, 1.0, 0.1).with_motor_lag(0.05),
]);
rw.command = RwCommand::Torques(vec![0.1]);
let state = test_state_at_rest();
let aux = vec![0.0, 0.0];
let mut rates = vec![0.0, 0.0];
let _loads = rw.derivatives(0.0, &state, &aux, &mut rates, None);
assert!((rates[1] - 2.0).abs() < 1e-12);
assert!(rates[0].abs() < 1e-15);
}
#[test]
fn assembly_motor_lag_partially_realized() {
let mut rw = RwAssembly::new(vec![
Rw::new(Vector3::x(), 0.01, 1.0, 0.1).with_motor_lag(0.05),
]);
rw.command = RwCommand::Torques(vec![0.1]);
let state = test_state_at_rest();
let aux = vec![0.0, 0.06];
let mut rates = vec![0.0, 0.0];
let loads = rw.derivatives(0.0, &state, &aux, &mut rates, None);
assert!((rates[1] - 0.8).abs() < 1e-12);
assert!((rates[0] - 0.06).abs() < 1e-12);
assert!((loads.torque_body.x() - (-0.06)).abs() < 1e-12);
}
#[test]
fn gyroscopic_coupling_with_spinning_body() {
let rw = RwAssembly::three_axis(0.01, 10.0, 0.5);
let state = AttitudeState {
quaternion: Vector4::new(1.0, 0.0, 0.0, 0.0),
angular_velocity: Vector3::new(0.0, 0.0, 1.0),
};
let aux = vec![5.0, 0.0, 0.0];
let mut rates = vec![0.0, 0.0, 0.0];
let loads = rw.derivatives(0.0, &state, &aux, &mut rates, None);
assert!(loads.torque_body.x().abs() < 1e-15);
assert!((loads.torque_body.y() - (-5.0)).abs() < 1e-15);
assert!(loads.torque_body.z().abs() < 1e-15);
}
}