use crate::MagneticReading;
use crate::types::{AngleEnable, Degrees, MagneticChannel, MilliTesla, SignedDegrees};
impl MagneticReading {
#[inline]
pub fn magnitude_3d(&self) -> Option<MilliTesla> {
match (self.x, self.y, self.z) {
(Some(x), Some(y), Some(z)) => {
Some(MilliTesla(libm::sqrtf(x.0 * x.0 + y.0 * y.0 + z.0 * z.0)))
}
_ => {
if self.x.is_none() {
defmt_debug!("magnitude_3d error: X axis disabled");
}
if self.y.is_none() {
defmt_debug!("magnitude_3d error: Y axis disabled");
}
if self.z.is_none() {
defmt_debug!("magnitude_3d error: Z axis disabled");
}
None
}
}
}
pub fn plane_angles(&self) -> PlaneAngles {
if self.x.is_none() {
defmt_debug!("plane_angles: X axis disabled");
}
if self.y.is_none() {
defmt_debug!("plane_angles: Y axis disabled");
}
if self.z.is_none() {
defmt_debug!("plane_angles: Z axis disabled");
}
let axes = [self.x, self.y, self.z];
const PLANES: [(usize, usize, PlaneAxis); 3] = [
(0, 1, PlaneAxis::XY),
(1, 2, PlaneAxis::YZ),
(0, 2, PlaneAxis::XZ),
];
let [xy, yz, xz] = PLANES.map(|(i, j, plane)| match (axes[i], axes[j]) {
(Some(axis_one), Some(axis_two)) => {
PlaneAngle::from_axes(axis_one.0, axis_two.0, plane)
}
_ => None,
});
PlaneAngles { xy, yz, xz }
}
}
impl From<PlaneAxis> for MagneticChannel {
#[inline]
fn from(plane: PlaneAxis) -> Self {
match plane {
PlaneAxis::XY => Self::XYX,
PlaneAxis::YZ => Self::YZY,
PlaneAxis::XZ => Self::XZX,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[repr(u8)]
pub enum PlaneAxis {
XY = 0,
YZ = 1,
XZ = 2,
}
impl_try_from_u8!(PlaneAxis {
0 => XY,
1 => YZ,
2 => XZ,
});
impl From<PlaneAxis> for AngleEnable {
#[inline]
fn from(plane: PlaneAxis) -> Self {
match plane {
PlaneAxis::XY => Self::XY,
PlaneAxis::YZ => Self::YZ,
PlaneAxis::XZ => Self::XZ,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[must_use]
pub struct PlaneAngle {
pub angle: Degrees,
pub magnitude: MilliTesla,
pub plane: PlaneAxis,
}
impl PlaneAngle {
fn from_axes(first: f32, second: f32, plane: PlaneAxis) -> Option<Self> {
if first == 0.0 && second == 0.0 {
return None;
}
let magnitude = MilliTesla(libm::sqrtf(first * first + second * second));
let mut angle = Degrees(libm::atan2f(second, first) * (180.0 / core::f32::consts::PI));
if angle < Degrees::MIN {
angle += SignedDegrees::MAX;
}
Some(Self {
angle,
magnitude,
plane,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[must_use]
pub struct PlaneAngles {
pub xy: Option<PlaneAngle>,
pub yz: Option<PlaneAngle>,
pub xz: Option<PlaneAngle>,
}
impl core::ops::Index<PlaneAxis> for PlaneAngles {
type Output = Option<PlaneAngle>;
fn index(&self, axis: PlaneAxis) -> &Self::Output {
match axis {
PlaneAxis::XY => &self.xy,
PlaneAxis::YZ => &self.yz,
PlaneAxis::XZ => &self.xz,
}
}
}
impl PlaneAngles {
pub fn dominant(&self) -> Option<PlaneAngle> {
[self.xy, self.yz, self.xz]
.into_iter()
.flatten()
.reduce(|best, candidate| {
if best.magnitude >= candidate.magnitude {
best
} else {
candidate
}
})
}
}
#[derive(Debug, Default, Clone, Copy)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct Welford {
count: u32,
mean: f32,
mean2: f32,
}
impl Welford {
pub fn update(&mut self, value: f32) {
if !value.is_finite() {
defmt_debug!("Welford:update: value is not finite");
return;
}
self.count = self.count.saturating_add(1);
let delta = value - self.mean;
self.mean += delta / (self.count as f32);
let delta2 = value - self.mean;
self.mean2 += delta * delta2;
}
pub fn count(&self) -> u32 {
self.count
}
pub fn mean(&self) -> Option<f32> {
if self.count < 2 {
return None;
}
Some(self.mean)
}
pub fn variance(&self) -> Option<f32> {
if self.count < 2 {
return None;
}
Some(self.mean2 / (self.count as f32))
}
pub fn sample_variance(&self) -> Option<f32> {
if self.count < 2 {
return None;
}
Some(self.mean2 / (self.count - 1) as f32)
}
pub fn stddev(&self) -> Option<f32> {
self.variance().map(libm::sqrtf)
}
pub fn sample_stddev(&self) -> Option<f32> {
self.sample_variance().map(libm::sqrtf)
}
pub fn abs_mean(&self) -> Option<f32> {
if self.count < 2 {
return None;
}
Some(libm::fabsf(self.mean))
}
}
#[derive(Debug, Default, Clone)]
pub struct AxisCalibrator {
stats: [Welford; 3],
}
impl AxisCalibrator {
pub const MIN_CALIBRATION_SAMPLES: u32 = 8;
pub fn update(&mut self, reading: &MagneticReading) {
for (stat, axis) in self.stats.iter_mut().zip([reading.x, reading.y, reading.z]) {
if let Some(mt) = axis {
stat.update(mt.0);
}
}
}
pub fn recommend(&self) -> Option<AxisRecommendation> {
self.recommend_inner(Welford::variance)
}
pub fn recommend_static(&self) -> Option<AxisRecommendation> {
self.recommend_inner(Welford::abs_mean)
}
fn recommend_inner(&self, scorer: fn(&Welford) -> Option<f32>) -> Option<AxisRecommendation> {
let qualifying = self
.stats
.iter()
.filter(|s| s.count >= Self::MIN_CALIBRATION_SAMPLES)
.count();
if qualifying < 2 {
return None;
}
let scores: [Option<f32>; 3] = core::array::from_fn(|i| {
if self.stats[i].count >= Self::MIN_CALIBRATION_SAMPLES {
scorer(&self.stats[i])
} else {
defmt_debug!(
"recommend: axis {} has {} samples (< {}), skipping",
i,
self.stats[i].count,
Self::MIN_CALIBRATION_SAMPLES
);
None
}
});
let mut ranked: [(Option<f32>, u8); 3] = core::array::from_fn(|i| (scores[i], i as u8));
if ranked[1].0 > ranked[0].0 {
ranked.swap(0, 1);
}
if ranked[2].0 > ranked[1].0 {
ranked.swap(1, 2);
}
if ranked[1].0 > ranked[0].0 {
ranked.swap(0, 1);
}
let (Some(_), a) = ranked[0] else {
defmt_debug!("recommend: top-ranked axis has no score");
return None;
};
let (Some(_), b) = ranked[1] else {
defmt_debug!("recommend: second-ranked axis has no score");
return None;
};
let axes = if a <= b { (a, b) } else { (b, a) };
let plane = match axes {
(0, 1) => PlaneAxis::XY,
(1, 2) => PlaneAxis::YZ,
(0, 2) => PlaneAxis::XZ,
_ => unreachable!(),
};
let angle_enable = AngleEnable::from(plane);
let channel = match plane {
PlaneAxis::XY => {
let x_score = scores[0].expect("X axis qualified but has no score");
let y_score = scores[1].expect("Y axis qualified but has no score");
if x_score >= y_score {
MagneticChannel::XYX
} else {
MagneticChannel::YXY
}
}
PlaneAxis::YZ => MagneticChannel::YZY,
PlaneAxis::XZ => MagneticChannel::XZX,
};
let variances: [Option<f32>; 3] = core::array::from_fn(|i| {
if self.stats[i].count >= Self::MIN_CALIBRATION_SAMPLES {
self.stats[i].variance()
} else {
None
}
});
let abs_means: [Option<f32>; 3] = core::array::from_fn(|i| {
if self.stats[i].count >= Self::MIN_CALIBRATION_SAMPLES {
self.stats[i].abs_mean()
} else {
None
}
});
Some(AxisRecommendation {
plane,
angle_enable,
magnetic_channel: channel,
variances,
abs_means,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[non_exhaustive]
#[must_use]
pub struct AxisRecommendation {
pub plane: PlaneAxis,
pub angle_enable: AngleEnable,
pub magnetic_channel: MagneticChannel,
pub variances: [Option<f32>; 3],
pub abs_means: [Option<f32>; 3],
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn magnitude_3d_all_none_returns_none() {
let r = MagneticReading {
x: None,
y: None,
z: None,
};
assert!(r.magnitude_3d().is_none());
}
#[test]
fn magnitude_3d_partial_axes_returns_none() {
let r = MagneticReading {
x: Some(MilliTesla(5.0)),
y: None,
z: None,
};
assert!(r.magnitude_3d().is_none());
let r = MagneticReading {
x: Some(MilliTesla(-3.0)),
y: Some(MilliTesla(-4.0)),
z: None,
};
assert!(r.magnitude_3d().is_none());
}
#[test]
fn magnitude_3d_all_axes() {
let r = MagneticReading {
x: Some(MilliTesla(1.0)),
y: Some(MilliTesla(2.0)),
z: Some(MilliTesla(2.0)),
};
assert!((r.magnitude_3d().unwrap().0 - 3.0).abs() < 0.01);
}
#[test]
fn magnitude_3d_zero_fields() {
let r = MagneticReading {
x: Some(MilliTesla(0.0)),
y: Some(MilliTesla(0.0)),
z: Some(MilliTesla(0.0)),
};
assert_eq!(r.magnitude_3d().unwrap().0, 0.0);
}
#[test]
fn magnitude_3d_negative_fields() {
let r = MagneticReading {
x: Some(MilliTesla(-3.0)),
y: Some(MilliTesla(-4.0)),
z: Some(MilliTesla(0.0)),
};
assert!((r.magnitude_3d().unwrap().0 - 5.0).abs() < 0.01);
}
#[test]
fn plane_angles_3_4_triangle() {
let r = MagneticReading {
x: Some(MilliTesla(3.0)),
y: Some(MilliTesla(4.0)),
z: Some(MilliTesla(0.0)),
};
let pa = r.plane_angles();
let xy = pa.xy.unwrap();
assert!((xy.angle.0 - 53.13).abs() < 0.01);
assert!((xy.magnitude.0 - 5.0).abs() < 0.01);
assert_eq!(xy.plane, PlaneAxis::XY);
}
#[test]
fn plane_angles_all_three_present() {
let r = MagneticReading {
x: Some(MilliTesla(1.0)),
y: Some(MilliTesla(2.0)),
z: Some(MilliTesla(3.0)),
};
let pa = r.plane_angles();
assert!(pa.xy.is_some());
assert!(pa.yz.is_some());
assert!(pa.xz.is_some());
}
#[test]
fn plane_angles_only_xy_present() {
let r = MagneticReading {
x: Some(MilliTesla(1.0)),
y: Some(MilliTesla(1.0)),
z: None,
};
let pa = r.plane_angles();
assert!(pa.xy.is_some());
assert!(pa.yz.is_none());
assert!(pa.xz.is_none());
}
#[test]
fn plane_angles_only_yz_present() {
let r = MagneticReading {
x: None,
y: Some(MilliTesla(1.0)),
z: Some(MilliTesla(1.0)),
};
let pa = r.plane_angles();
assert!(pa.xy.is_none());
assert!(pa.yz.is_some());
assert!(pa.xz.is_none());
}
#[test]
fn plane_angles_single_axis_all_none() {
let r = MagneticReading {
x: Some(MilliTesla(5.0)),
y: None,
z: None,
};
let pa = r.plane_angles();
assert!(pa.xy.is_none());
assert!(pa.yz.is_none());
assert!(pa.xz.is_none());
}
#[test]
fn plane_angles_all_axes_none() {
let r = MagneticReading {
x: None,
y: None,
z: None,
};
let pa = r.plane_angles();
assert!(pa.xy.is_none());
assert!(pa.yz.is_none());
assert!(pa.xz.is_none());
}
#[test]
fn plane_angles_zero_magnitude_returns_none() {
let r = MagneticReading {
x: Some(MilliTesla(0.0)),
y: Some(MilliTesla(0.0)),
z: Some(MilliTesla(0.0)),
};
let pa = r.plane_angles();
assert!(pa.xy.is_none());
assert!(pa.yz.is_none());
assert!(pa.xz.is_none());
}
#[test]
fn plane_angles_one_axis_zero_other_nonzero() {
let r = MagneticReading {
x: Some(MilliTesla(3.0)),
y: Some(MilliTesla(0.0)),
z: None,
};
let xy = r.plane_angles().xy.unwrap();
assert!((xy.angle.0 - 0.0).abs() < 0.01);
assert!((xy.magnitude.0 - 3.0).abs() < 0.01);
}
#[test]
fn plane_angles_negative_values_correct_quadrant() {
let r = MagneticReading {
x: Some(MilliTesla(-3.0)),
y: Some(MilliTesla(4.0)),
z: None,
};
let xy = r.plane_angles().xy.unwrap();
assert!((xy.angle.0 - 126.87).abs() < 0.01);
let r = MagneticReading {
x: Some(MilliTesla(-3.0)),
y: Some(MilliTesla(-4.0)),
z: None,
};
let xy = r.plane_angles().xy.unwrap();
assert!((xy.angle.0 - 233.13).abs() < 0.01);
let r = MagneticReading {
x: Some(MilliTesla(3.0)),
y: Some(MilliTesla(-4.0)),
z: None,
};
let xy = r.plane_angles().xy.unwrap();
assert!((xy.angle.0 - 306.87).abs() < 0.01);
}
#[test]
fn plane_angles_range_0_360() {
let r = MagneticReading {
x: Some(MilliTesla(0.0)),
y: Some(MilliTesla(-1.0)),
z: None,
};
let xy = r.plane_angles().xy.unwrap();
assert!((xy.angle.0 - 270.0).abs() < 0.01);
}
#[test]
fn plane_angles_datasheet_parity_xy_atan2_y_x() {
let r = MagneticReading {
x: Some(MilliTesla(3.0)),
y: Some(MilliTesla(4.0)),
z: None,
};
let xy = r.plane_angles().xy.unwrap();
let expected = libm::atan2f(4.0, 3.0) * (180.0 / core::f32::consts::PI);
assert!((xy.angle.0 - expected).abs() < 0.01);
}
#[test]
fn plane_angles_datasheet_parity_yz_atan2_z_y() {
let r = MagneticReading {
x: None,
y: Some(MilliTesla(3.0)),
z: Some(MilliTesla(4.0)),
};
let yz = r.plane_angles().yz.unwrap();
let expected = libm::atan2f(4.0, 3.0) * (180.0 / core::f32::consts::PI);
assert!((yz.angle.0 - expected).abs() < 0.01);
assert_eq!(yz.plane, PlaneAxis::YZ);
}
#[test]
fn plane_angles_datasheet_parity_xz_atan2_z_x() {
let r = MagneticReading {
x: Some(MilliTesla(3.0)),
y: None,
z: Some(MilliTesla(4.0)),
};
let xz = r.plane_angles().xz.unwrap();
let expected = libm::atan2f(4.0, 3.0) * (180.0 / core::f32::consts::PI);
assert!((xz.angle.0 - expected).abs() < 0.01);
assert_eq!(xz.plane, PlaneAxis::XZ);
}
#[test]
fn dominant_picks_largest_magnitude() {
let r = MagneticReading {
x: Some(MilliTesla(1.0)),
y: Some(MilliTesla(2.0)),
z: Some(MilliTesla(10.0)),
};
let d = r.plane_angles().dominant().unwrap();
assert_eq!(d.plane, PlaneAxis::YZ);
}
#[test]
fn dominant_single_plane_returns_it() {
let r = MagneticReading {
x: Some(MilliTesla(3.0)),
y: Some(MilliTesla(4.0)),
z: None,
};
let d = r.plane_angles().dominant().unwrap();
assert_eq!(d.plane, PlaneAxis::XY);
}
#[test]
fn dominant_equal_magnitude_prefers_xy() {
let r = MagneticReading {
x: Some(MilliTesla(1.0)),
y: Some(MilliTesla(1.0)),
z: Some(MilliTesla(1.0)),
};
let d = r.plane_angles().dominant().unwrap();
assert_eq!(d.plane, PlaneAxis::XY);
}
#[test]
fn dominant_all_none_returns_none() {
let r = MagneticReading {
x: None,
y: None,
z: None,
};
assert!(r.plane_angles().dominant().is_none());
}
#[test]
fn calibrator_known_values() {
let mut cal = AxisCalibrator::default();
for i in 0..10 {
cal.update(&MagneticReading {
x: Some(MilliTesla(i as f32)),
y: Some(MilliTesla(10.0)),
z: Some(MilliTesla(-5.0)),
});
}
assert_eq!(cal.stats[0].count, 10);
assert_eq!(cal.stats[1].count, 10);
assert_eq!(cal.stats[2].count, 10);
assert!((cal.stats[0].mean - 4.5).abs() < 1e-6);
assert!((cal.stats[1].variance().unwrap()).abs() < 1e-6);
}
#[test]
fn calibrator_skips_none_axes() {
let mut cal = AxisCalibrator::default();
for _ in 0..5 {
cal.update(&MagneticReading {
x: Some(MilliTesla(1.0)),
y: None,
z: Some(MilliTesla(2.0)),
});
}
assert_eq!(cal.stats[0].count, 5);
assert_eq!(cal.stats[1].count, 0); assert_eq!(cal.stats[2].count, 5);
}
#[test]
fn calibrator_single_sample_variance_none() {
let mut cal = AxisCalibrator::default();
cal.update(&MagneticReading {
x: Some(MilliTesla(5.0)),
y: Some(MilliTesla(5.0)),
z: Some(MilliTesla(5.0)),
});
assert!(cal.stats[0].variance().is_none());
}
#[test]
fn calibrator_two_identical_samples_zero_variance() {
let mut cal = AxisCalibrator::default();
for _ in 0..2 {
cal.update(&MagneticReading {
x: Some(MilliTesla(3.0)),
y: None,
z: None,
});
}
assert!((cal.stats[0].variance().unwrap() - 0.0).abs() < 1e-6);
}
#[test]
fn calibrator_all_none_counts_zero() {
let mut cal = AxisCalibrator::default();
for _ in 0..10 {
cal.update(&MagneticReading {
x: None,
y: None,
z: None,
});
}
assert_eq!(cal.stats[0].count, 0);
assert_eq!(cal.stats[1].count, 0);
assert_eq!(cal.stats[2].count, 0);
}
#[test]
fn calibrator_welford_known_variance() {
let mut cal = AxisCalibrator::default();
cal.update(&MagneticReading {
x: Some(MilliTesla(1.0)),
y: None,
z: None,
});
cal.update(&MagneticReading {
x: Some(MilliTesla(3.0)),
y: None,
z: None,
});
let var = cal.stats[0].variance().unwrap();
assert!((var - 1.0).abs() < 1e-6);
}
#[test]
fn calibrator_dc_biased_welford_stability() {
let mut cal = AxisCalibrator::default();
for &v in &[50.0_f32, 51.0, 49.0, 50.5] {
cal.update(&MagneticReading {
x: Some(MilliTesla(v)),
y: None,
z: None,
});
}
let var = cal.stats[0].variance().unwrap();
assert!(
(var - 0.546875).abs() < 1e-4,
"DC-biased variance: expected ~0.546875, got {var}"
);
}
#[test]
fn welford_nan_input_ignored() {
let mut cal = AxisCalibrator::default();
for v in [1.0, 2.0, 3.0, 4.0] {
cal.update(&MagneticReading {
x: Some(MilliTesla(v)),
y: None,
z: None,
});
}
let count_before = cal.stats[0].count;
let mean_before = cal.stats[0].mean;
cal.update(&MagneticReading {
x: Some(MilliTesla(f32::NAN)),
y: None,
z: None,
});
assert_eq!(cal.stats[0].count, count_before);
assert_eq!(cal.stats[0].mean, mean_before);
}
#[test]
fn welford_infinity_input_ignored() {
let mut cal = AxisCalibrator::default();
cal.update(&MagneticReading {
x: Some(MilliTesla(1.0)),
y: None,
z: None,
});
let count_before = cal.stats[0].count;
cal.update(&MagneticReading {
x: Some(MilliTesla(f32::INFINITY)),
y: None,
z: None,
});
assert_eq!(cal.stats[0].count, count_before);
}
#[test]
fn welford_count_saturates_at_max() {
let mut w = Welford::default();
w.update(10.0);
w.update(12.0);
w.update(11.0);
assert_eq!(w.count, 3);
w.count = u32::MAX - 1;
w.update(11.0);
assert_eq!(w.count, u32::MAX, "count should saturate at u32::MAX");
w.update(11.0);
assert_eq!(w.count, u32::MAX, "count stays saturated");
assert!(w.mean.is_finite(), "mean must stay finite after saturation");
assert!(
w.mean2.is_finite(),
"mean2 must stay finite after saturation"
);
let v = w.variance();
assert!(v.is_some(), "variance should be Some after saturation");
assert!(v.unwrap().is_finite(), "variance must be finite");
}
fn feed_rotation(cal: &mut AxisCalibrator, samples: u32, x_amp: f32, y_amp: f32, z_const: f32) {
for i in 0..samples {
let t = (i as f32) / (samples as f32) * 2.0 * core::f32::consts::PI;
cal.update(&MagneticReading {
x: Some(MilliTesla(x_amp * libm::sinf(t))),
y: Some(MilliTesla(y_amp * libm::cosf(t))),
z: Some(MilliTesla(z_const)),
});
}
}
#[test]
fn recommend_xy_rotation() {
let mut cal = AxisCalibrator::default();
feed_rotation(&mut cal, 16, 10.0, 5.0, 1.0);
let rec = cal.recommend().unwrap();
assert_eq!(rec.plane, PlaneAxis::XY);
assert_eq!(rec.angle_enable, AngleEnable::XY);
assert_eq!(rec.magnetic_channel, MagneticChannel::XYX);
}
#[test]
fn recommend_xy_y_higher_variance() {
let mut cal = AxisCalibrator::default();
feed_rotation(&mut cal, 16, 3.0, 10.0, 0.5);
let rec = cal.recommend().unwrap();
assert_eq!(rec.plane, PlaneAxis::XY);
assert_eq!(rec.magnetic_channel, MagneticChannel::YXY);
}
#[test]
fn recommend_yz_rotation() {
let mut cal = AxisCalibrator::default();
for i in 0..16 {
let t = (i as f32) / 16.0 * 2.0 * core::f32::consts::PI;
cal.update(&MagneticReading {
x: Some(MilliTesla(0.1)),
y: Some(MilliTesla(10.0 * libm::sinf(t))),
z: Some(MilliTesla(8.0 * libm::cosf(t))),
});
}
let rec = cal.recommend().unwrap();
assert_eq!(rec.plane, PlaneAxis::YZ);
assert_eq!(rec.magnetic_channel, MagneticChannel::YZY);
}
#[test]
fn recommend_xz_rotation() {
let mut cal = AxisCalibrator::default();
for i in 0..16 {
let t = (i as f32) / 16.0 * 2.0 * core::f32::consts::PI;
cal.update(&MagneticReading {
x: Some(MilliTesla(10.0 * libm::sinf(t))),
y: Some(MilliTesla(0.1)),
z: Some(MilliTesla(8.0 * libm::cosf(t))),
});
}
let rec = cal.recommend().unwrap();
assert_eq!(rec.plane, PlaneAxis::XZ);
assert_eq!(rec.magnetic_channel, MagneticChannel::XZX);
}
#[test]
fn recommend_variances_field_correct() {
let mut cal = AxisCalibrator::default();
feed_rotation(&mut cal, 16, 10.0, 5.0, 1.0);
let rec = cal.recommend().unwrap();
assert!(rec.variances[0].is_some());
assert!(rec.variances[1].is_some());
assert!(rec.variances[2].is_some());
assert!(rec.variances[0].unwrap() > rec.variances[1].unwrap());
assert!(rec.variances[1].unwrap() > rec.variances[2].unwrap());
}
#[test]
fn recommend_too_few_samples_returns_none() {
let mut cal = AxisCalibrator::default();
feed_rotation(&mut cal, 4, 10.0, 5.0, 1.0);
assert!(cal.recommend().is_none());
}
#[test]
fn recommend_only_one_axis_returns_none() {
let mut cal = AxisCalibrator::default();
for i in 0..16 {
cal.update(&MagneticReading {
x: Some(MilliTesla(i as f32)),
y: None,
z: None,
});
}
assert!(cal.recommend().is_none());
}
#[test]
fn recommend_two_axes_sufficient() {
let mut cal = AxisCalibrator::default();
for i in 0..16 {
let t = (i as f32) / 16.0 * 2.0 * core::f32::consts::PI;
cal.update(&MagneticReading {
x: Some(MilliTesla(10.0 * libm::sinf(t))),
y: Some(MilliTesla(5.0 * libm::cosf(t))),
z: None,
});
}
let rec = cal.recommend().unwrap();
assert_eq!(rec.plane, PlaneAxis::XY);
assert!(rec.variances[2].is_none()); }
#[test]
fn recommend_subthreshold_axis_variance_is_none() {
let mut cal = AxisCalibrator::default();
for i in 0..16_u32 {
let t = (i as f32) / 16.0 * 2.0 * core::f32::consts::PI;
cal.update(&MagneticReading {
x: Some(MilliTesla(10.0 * libm::sinf(t))),
y: Some(MilliTesla(5.0 * libm::cosf(t))),
z: if i < 5 {
Some(MilliTesla(i as f32))
} else {
None
},
});
}
assert_eq!(cal.stats[2].count, 5);
assert!(
cal.stats[2].variance().is_some(),
"Welford should return Some for 5 samples"
);
let rec = cal.recommend().unwrap();
assert_eq!(rec.plane, PlaneAxis::XY);
assert!(rec.variances[0].is_some()); assert!(rec.variances[1].is_some()); assert!(
rec.variances[2].is_none(),
"Z has 5 samples (< MIN_CALIBRATION_SAMPLES=8): must be None, not Some"
);
}
#[test]
fn recommend_isotropic_noise_returns_xy() {
let mut cal = AxisCalibrator::default();
for i in 0..16 {
let v = (i as f32) * 0.5;
cal.update(&MagneticReading {
x: Some(MilliTesla(v)),
y: Some(MilliTesla(v)),
z: Some(MilliTesla(v)),
});
}
let rec = cal.recommend().unwrap();
assert_eq!(rec.plane, PlaneAxis::XY);
}
#[test]
fn recommend_zero_samples_returns_none() {
let cal = AxisCalibrator::default();
assert!(cal.recommend().is_none());
}
#[test]
fn recommend_exactly_min_samples_succeeds() {
let mut cal = AxisCalibrator::default();
for i in 0..AxisCalibrator::MIN_CALIBRATION_SAMPLES {
let t = (i as f32) / (AxisCalibrator::MIN_CALIBRATION_SAMPLES as f32)
* 2.0
* core::f32::consts::PI;
cal.update(&MagneticReading {
x: Some(MilliTesla(10.0 * libm::sinf(t))),
y: Some(MilliTesla(5.0 * libm::cosf(t))),
z: None,
});
}
assert!(cal.recommend().is_some());
}
#[test]
fn recommend_one_below_min_samples_returns_none() {
let mut cal = AxisCalibrator::default();
for i in 0..(AxisCalibrator::MIN_CALIBRATION_SAMPLES - 1) {
let t = (i as f32) / 7.0 * 2.0 * core::f32::consts::PI;
cal.update(&MagneticReading {
x: Some(MilliTesla(10.0 * libm::sinf(t))),
y: Some(MilliTesla(5.0 * libm::cosf(t))),
z: None,
});
}
assert!(cal.recommend().is_none());
}
#[test]
fn recommend_output_accepted_by_config_builder() {
use crate::config::ConfigBuilder;
use crate::types::Range;
let mut cal = AxisCalibrator::default();
feed_rotation(&mut cal, 16, 10.0, 5.0, 1.0);
let rec = cal.recommend().unwrap();
let result = ConfigBuilder::new()
.angle_enabled(rec.angle_enable)
.magnetic_channels_enabled(rec.magnetic_channel)
.build();
assert!(
result.is_ok(),
"ConfigBuilder rejected XY recommendation: {result:?}"
);
let mut cal = AxisCalibrator::default();
for i in 0..16 {
let t = (i as f32) / 16.0 * 2.0 * core::f32::consts::PI;
cal.update(&MagneticReading {
x: Some(MilliTesla(0.1)),
y: Some(MilliTesla(10.0 * libm::sinf(t))),
z: Some(MilliTesla(8.0 * libm::cosf(t))),
});
}
let rec = cal.recommend().unwrap();
assert_eq!(rec.plane, PlaneAxis::YZ);
let result = ConfigBuilder::new()
.angle_enabled(rec.angle_enable)
.magnetic_channels_enabled(rec.magnetic_channel)
.xy_range(Range::High)
.z_range(Range::High)
.build();
assert!(
result.is_ok(),
"ConfigBuilder rejected YZ recommendation: {result:?}"
);
}
#[test]
fn recommend_static_picks_strongest_axes() {
let mut cal = AxisCalibrator::default();
for _ in 0..16 {
cal.update(&MagneticReading {
x: Some(MilliTesla(2.0)),
y: Some(MilliTesla(1.0)),
z: Some(MilliTesla(10.0)),
});
}
let rec = cal.recommend_static().unwrap();
assert_eq!(rec.plane, PlaneAxis::XZ);
assert_eq!(rec.magnetic_channel, MagneticChannel::XZX);
}
#[test]
fn recommend_static_negative_field_uses_abs() {
let mut cal = AxisCalibrator::default();
for _ in 0..16 {
cal.update(&MagneticReading {
x: Some(MilliTesla(-2.0)),
y: Some(MilliTesla(0.5)),
z: Some(MilliTesla(-10.0)),
});
}
let rec = cal.recommend_static().unwrap();
assert_eq!(rec.plane, PlaneAxis::XZ);
}
#[test]
fn recommend_static_yz_when_y_z_strongest() {
let mut cal = AxisCalibrator::default();
for _ in 0..16 {
cal.update(&MagneticReading {
x: Some(MilliTesla(0.1)),
y: Some(MilliTesla(8.0)),
z: Some(MilliTesla(5.0)),
});
}
let rec = cal.recommend_static().unwrap();
assert_eq!(rec.plane, PlaneAxis::YZ);
}
#[test]
fn recommend_static_equal_means_defaults_xy() {
let mut cal = AxisCalibrator::default();
for _ in 0..16 {
cal.update(&MagneticReading {
x: Some(MilliTesla(5.0)),
y: Some(MilliTesla(5.0)),
z: Some(MilliTesla(5.0)),
});
}
let rec = cal.recommend_static().unwrap();
assert_eq!(rec.plane, PlaneAxis::XY);
}
#[test]
fn recommend_static_abs_means_populated() {
let mut cal = AxisCalibrator::default();
for _ in 0..16 {
cal.update(&MagneticReading {
x: Some(MilliTesla(-3.0)),
y: Some(MilliTesla(7.0)),
z: Some(MilliTesla(0.0)),
});
}
let rec = cal.recommend_static().unwrap();
assert!((rec.abs_means[0].unwrap() - 3.0).abs() < 0.01);
assert!((rec.abs_means[1].unwrap() - 7.0).abs() < 0.01);
assert!((rec.abs_means[2].unwrap() - 0.0).abs() < 0.01);
}
#[test]
fn recommend_static_too_few_samples_returns_none() {
let cal = AxisCalibrator::default();
assert!(cal.recommend_static().is_none());
}
#[test]
fn recommend_static_output_accepted_by_config_builder() {
use crate::config::ConfigBuilder;
use crate::types::Range;
let mut cal = AxisCalibrator::default();
for _ in 0..16 {
cal.update(&MagneticReading {
x: Some(MilliTesla(1.0)),
y: Some(MilliTesla(5.0)),
z: Some(MilliTesla(10.0)),
});
}
let rec = cal.recommend_static().unwrap();
assert_eq!(rec.plane, PlaneAxis::YZ);
let result = ConfigBuilder::new()
.angle_enabled(rec.angle_enable)
.magnetic_channels_enabled(rec.magnetic_channel)
.xy_range(Range::High)
.z_range(Range::High)
.build();
assert!(
result.is_ok(),
"ConfigBuilder rejected recommend_static YZ: {result:?}"
);
}
#[test]
fn recommend_variance_still_works_after_refactor() {
let mut cal = AxisCalibrator::default();
feed_rotation(&mut cal, 16, 10.0, 5.0, 1.0);
let rec = cal.recommend().unwrap();
assert_eq!(rec.plane, PlaneAxis::XY);
assert_eq!(rec.magnetic_channel, MagneticChannel::XYX);
assert!(rec.abs_means[0].is_some());
assert!(rec.abs_means[1].is_some());
assert!(rec.abs_means[2].is_some());
}
#[test]
fn axis_plane_to_angle_enable() {
assert_eq!(AngleEnable::from(PlaneAxis::XY), AngleEnable::XY);
assert_eq!(AngleEnable::from(PlaneAxis::YZ), AngleEnable::YZ);
assert_eq!(AngleEnable::from(PlaneAxis::XZ), AngleEnable::XZ);
}
#[test]
fn axis_plane_to_magnetic_channel() {
assert_eq!(MagneticChannel::from(PlaneAxis::XY), MagneticChannel::XYX);
assert_eq!(MagneticChannel::from(PlaneAxis::YZ), MagneticChannel::YZY);
assert_eq!(MagneticChannel::from(PlaneAxis::XZ), MagneticChannel::XZX);
}
#[test]
fn plane_angle_fields_accessible() {
let pa = PlaneAngle {
angle: Degrees(45.0),
magnitude: MilliTesla(10.0),
plane: PlaneAxis::XY,
};
assert_eq!(pa.angle.0, 45.0);
assert_eq!(pa.magnitude.0, 10.0);
assert_eq!(pa.plane, PlaneAxis::XY);
}
}