use core::mem::size_of;
use crate::types::{Crossings, Degrees, MicrosIsr, MilliTesla, PoleCount, Rpm, SignedDegrees};
mod private {
use crate::{PoleCount, Rpm, SignedDegrees};
pub trait Sealed {
fn rpm(&self, poles: PoleCount) -> Option<Rpm>;
fn cumulative_revolutions(&self, poles: PoleCount) -> f32;
fn reset(&mut self);
fn max_abs_delta(&self) -> Option<SignedDegrees>;
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct Cordic {
previous_angle: Option<Degrees>,
revolutions: i32,
fractional_angle: Degrees,
last_velocity_dps: f32,
max_abs_delta: f32,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct ZeroCrossing {
hysteresis: MilliTesla,
crossings: Crossings,
elapsed: MicrosIsr,
ipi: MicrosIsr,
state_high: Option<bool>,
}
impl private::Sealed for Cordic {
fn rpm(&self, poles: PoleCount) -> Option<Rpm> {
debug_assert!(
poles == PoleCount::Two,
"Cordic RPM math is only valid for POLES_COUNT=2 (diametrically magnetized magnet)"
);
if self.last_velocity_dps.is_nan() {
return None;
}
let pole_pairs = (poles.count() / 2) as f32;
let rpm_raw = self.last_velocity_dps / (Degrees::MAX.0 * pole_pairs) * 60.0;
Some(Rpm(rpm_raw))
}
fn cumulative_revolutions(&self, poles: PoleCount) -> f32 {
debug_assert!(
poles == PoleCount::Two,
"Cordic cumulative_revolutions is only valid for POLES_COUNT=2 (diametrically magnetized magnet)"
);
let pole_pairs = (poles.count() / 2) as f32;
let revolutions = (self.revolutions as f32) + self.fractional_angle.0 / Degrees::MAX.0;
revolutions / pole_pairs
}
fn reset(&mut self) {
self.previous_angle = None;
self.revolutions = 0;
self.fractional_angle = Degrees::MIN;
self.last_velocity_dps = f32::NAN;
self.max_abs_delta = f32::NAN;
}
fn max_abs_delta(&self) -> Option<SignedDegrees> {
if self.max_abs_delta.is_nan() {
None
} else {
Some(SignedDegrees(self.max_abs_delta))
}
}
}
impl private::Sealed for ZeroCrossing {
fn rpm(&self, poles: PoleCount) -> Option<Rpm> {
if self.crossings == Crossings(0) || self.elapsed.0 == 0 {
return None;
}
let revolutions = self.crossings.0 as f32 / poles.count() as f32;
Some(Rpm(revolutions / self.elapsed.to_seconds() * 60.0))
}
fn cumulative_revolutions(&self, poles: PoleCount) -> f32 {
self.crossings.0 as f32 / poles.count() as f32
}
fn reset(&mut self) {
self.crossings = Crossings(0);
self.elapsed = MicrosIsr(0);
self.ipi = MicrosIsr(0);
self.state_high = None;
}
fn max_abs_delta(&self) -> Option<SignedDegrees> {
None
}
}
pub trait TrackingMode: private::Sealed {}
impl TrackingMode for Cordic {}
impl TrackingMode for ZeroCrossing {}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub struct RotationTracker<const POLES_COUNT: u8, M: TrackingMode> {
mode: M,
}
impl<const POLES_COUNT: u8, M: TrackingMode> RotationTracker<POLES_COUNT, M> {
#[inline]
pub const fn poles(&self) -> PoleCount {
match POLES_COUNT {
2 => PoleCount::Two,
3 => PoleCount::Three,
4 => PoleCount::Four,
_ => panic!("POLES_COUNT must be 2, 3, or 4"),
}
}
pub fn rpm(&self) -> Option<Rpm> {
self.mode.rpm(self.poles())
}
pub fn cumulative_revolutions(&self) -> f32 {
self.mode.cumulative_revolutions(self.poles())
}
pub fn reset(&mut self) {
self.mode.reset();
}
pub fn max_abs_delta(&self) -> Option<SignedDegrees> {
self.mode.max_abs_delta()
}
}
impl Default for RotationTracker<2, Cordic> {
fn default() -> Self {
Self::new()
}
}
impl RotationTracker<2, Cordic> {
pub fn new() -> Self {
Self {
mode: Cordic {
previous_angle: None,
revolutions: 0,
fractional_angle: Degrees::MIN,
last_velocity_dps: f32::NAN,
max_abs_delta: f32::NAN,
},
}
}
}
impl RotationTracker<2, Cordic> {
pub fn update(&mut self, angle: Degrees, elapsed: MicrosIsr) -> Option<SignedDegrees> {
if !angle.is_valid() {
defmt_warn!("update: angle {}° is non-finite, ignoring sample", angle.0);
return None;
}
let Some(previous_angle) = self.mode.previous_angle else {
self.mode.previous_angle = Some(angle);
self.mode.fractional_angle = angle;
return None;
};
let mut delta = SignedDegrees(angle.0 - previous_angle.0);
if delta > SignedDegrees(180.0) {
delta -= SignedDegrees::MAX;
} else if delta <= -SignedDegrees(180.0) {
delta += SignedDegrees::MAX;
}
self.mode.previous_angle = Some(angle);
let abs_delta = delta.abs();
if self.mode.max_abs_delta.is_nan() || abs_delta.0 > self.mode.max_abs_delta {
self.mode.max_abs_delta = abs_delta.0;
}
self.mode.fractional_angle += delta;
while self.mode.fractional_angle >= Degrees::MAX {
self.mode.revolutions += 1;
self.mode.fractional_angle -= SignedDegrees::MAX;
}
while self.mode.fractional_angle < Degrees::MIN {
self.mode.revolutions -= 1;
self.mode.fractional_angle += SignedDegrees::MAX;
}
let secs = elapsed.to_seconds();
self.mode.last_velocity_dps = if secs > 0.0 { delta.0 / secs } else { f32::NAN };
Some(delta)
}
pub fn accumulated_electrical_angle(&self) -> f32 {
(self.mode.revolutions as f32) * Degrees::MAX.0 + self.mode.fractional_angle.0
}
pub fn angular_velocity_dps(&self) -> Option<f32> {
if self.mode.last_velocity_dps.is_nan() {
None
} else {
Some(self.mode.last_velocity_dps)
}
}
}
impl<const POLES_COUNT: u8> RotationTracker<POLES_COUNT, ZeroCrossing> {
pub fn new(hysteresis: MilliTesla) -> Self {
const {
assert!(
POLES_COUNT >= 2 && POLES_COUNT <= 4,
"POLES_COUNT must be 2, 3, or 4"
)
};
Self {
mode: ZeroCrossing {
hysteresis: MilliTesla(hysteresis.0.max(0.0)),
crossings: Crossings(0),
elapsed: MicrosIsr(0),
ipi: MicrosIsr(0),
state_high: None,
},
}
}
pub fn update(&mut self, value: MilliTesla, elapsed: MicrosIsr) -> Option<MicrosIsr> {
let hysteresis = self.mode.hysteresis;
self.mode.ipi = MicrosIsr(self.mode.ipi.0.saturating_add(elapsed.0));
self.mode.elapsed = MicrosIsr(self.mode.elapsed.0.saturating_add(elapsed.0));
let crossed = match self.mode.state_high {
Some(true) => {
if value <= -hysteresis {
self.mode.state_high = Some(false);
self.mode.crossings = self.mode.crossings.saturating_add(1);
true
} else {
false
}
}
Some(false) => {
if value >= hysteresis {
self.mode.state_high = Some(true);
self.mode.crossings = self.mode.crossings.saturating_add(1);
true
} else {
false
}
}
None => {
if value >= hysteresis {
self.mode.state_high = Some(true);
} else if value <= -hysteresis {
self.mode.state_high = Some(false);
}
false
}
};
if crossed {
let interval = self.mode.ipi;
self.mode.ipi = MicrosIsr(0);
if self.mode.crossings > Crossings(1) {
Some(interval)
} else {
None
}
} else {
None
}
}
pub fn crossings(&self) -> Crossings {
self.mode.crossings
}
}
pub type CordicTracker = RotationTracker<2, Cordic>;
const _: () = assert!(
size_of::<RotationTracker<2, Cordic>>() <= 24,
"RotationTracker<2, Cordic> exceeds 24-byte struct size budget"
);
const _: () = assert!(
size_of::<RotationTracker<4, ZeroCrossing>>() <= 24,
"RotationTracker<4, ZeroCrossing> exceeds 24-byte struct size budget"
);
#[cfg(test)]
mod tests {
use super::*;
fn mt(value: f32) -> MilliTesla {
MilliTesla(value)
}
#[test]
fn cordic_sequential_angles_accumulate() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees::MIN, MicrosIsr(0));
t.update(Degrees(90.0), MicrosIsr(10_000));
t.update(Degrees(180.0), MicrosIsr(10_000));
t.update(Degrees(270.0), MicrosIsr(10_000));
let ed = t.accumulated_electrical_angle();
assert!((ed - 270.0).abs() < 0.01, "expected ~270, got {}", ed);
let v = t.angular_velocity_dps().unwrap();
assert!((v - 9000.0).abs() < 1.0, "expected ~9000, got {}", v);
}
#[test]
fn cordic_full_revolution_single_pole() {
let mut t = RotationTracker::<2, Cordic>::new();
let angles = [0.0, 90.0, 180.0, 270.0, 0.0]; for &a in &angles {
t.update(Degrees(a), MicrosIsr(10_000));
}
let rev = t.cumulative_revolutions();
assert!((rev - 1.0).abs() < 0.01, "expected ~1.0, got {}", rev);
}
#[test]
fn cordic_rpm_single_pole_pair() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees::MIN, MicrosIsr(0));
t.update(Degrees(90.0), MicrosIsr(1000));
let rpm = t.rpm().unwrap();
assert!(
(rpm.0 - 15_000.0).abs() < 1.0,
"expected 15000, got {}",
rpm.0
);
}
#[test]
fn cordic_forward_wraparound_350_to_10() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees(350.0), MicrosIsr(0));
let delta = t.update(Degrees(10.0), MicrosIsr(1000));
let d = delta.unwrap();
assert!(
(d - SignedDegrees(20.0)).abs() < SignedDegrees(0.01),
"expected +20, got {:?}",
d
);
}
#[test]
fn cordic_backward_wraparound_10_to_350() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees(10.0), MicrosIsr(0));
let delta = t.update(Degrees(350.0), MicrosIsr(1000));
let d = delta.unwrap();
assert!(
(d - SignedDegrees(-20.0)).abs() < SignedDegrees(0.01),
"expected -20, got {:?}",
d
);
}
#[test]
fn cordic_backward_accumulation_underflow() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees(10.0), MicrosIsr(0));
t.update(Degrees(350.0), MicrosIsr(1000)); t.update(Degrees(330.0), MicrosIsr(1000)); t.update(Degrees(310.0), MicrosIsr(1000));
let rev = t.cumulative_revolutions();
let expected = (-1.0_f32 + 310.0 / Degrees::MAX.0) / 1.0; assert!(
(rev - expected).abs() < 0.01,
"expected ~{}, got {}",
expected,
rev
);
assert!(
t.mode.fractional_angle >= Degrees::MIN && t.mode.fractional_angle < Degrees::MAX,
"fractional_angle out of range: {:?}",
t.mode.fractional_angle
);
assert!(
t.mode.revolutions < 0,
"revolutions should be negative: {}",
t.mode.revolutions
);
}
#[test]
fn cordic_first_update_no_velocity() {
let mut t = RotationTracker::<2, Cordic>::new();
let delta = t.update(Degrees(45.0), MicrosIsr(1000));
assert!(delta.is_none());
assert!(t.angular_velocity_dps().is_none());
assert!(t.rpm().is_none());
}
#[test]
fn cordic_zero_elapsed_no_velocity() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees::MIN, MicrosIsr(1000));
t.update(Degrees(90.0), MicrosIsr(0));
assert!(t.angular_velocity_dps().is_none());
assert!(t.rpm().is_none());
}
#[test]
fn cordic_identical_angles_zero_delta() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees(180.0), MicrosIsr(0));
let delta = t.update(Degrees(180.0), MicrosIsr(1000));
assert_eq!(delta, Some(SignedDegrees(0.0)));
let v = t.angular_velocity_dps().unwrap();
assert_eq!(v, 0.0);
}
#[test]
fn cordic_half_turn_treated_as_forward() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees::MIN, MicrosIsr(0));
let delta = t.update(Degrees(180.0), MicrosIsr(1000));
let d = delta.unwrap();
assert!(
(d - SignedDegrees(180.0)).abs() < SignedDegrees(0.01),
"expected +180, got {:?}",
d
);
}
#[test]
fn cordic_nan_input_ignored() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees(90.0), MicrosIsr(0));
t.update(Degrees(180.0), MicrosIsr(1000));
let rev_before = t.cumulative_revolutions();
let max_before = t.max_abs_delta();
let delta = t.update(Degrees(f32::NAN), MicrosIsr(1000));
assert!(delta.is_none());
assert_eq!(t.cumulative_revolutions(), rev_before);
assert_eq!(
t.max_abs_delta(),
max_before,
"NaN must not corrupt max_abs_delta"
);
}
#[test]
fn cordic_infinity_input_ignored() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees::MIN, MicrosIsr(0));
let rev_before = t.cumulative_revolutions();
let delta = t.update(Degrees(f32::INFINITY), MicrosIsr(1000));
assert!(delta.is_none());
assert_eq!(t.cumulative_revolutions(), rev_before);
}
#[test]
fn cordic_neg_infinity_input_ignored() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees(90.0), MicrosIsr(0));
let delta = t.update(Degrees(f32::NEG_INFINITY), MicrosIsr(1000));
assert!(delta.is_none());
}
#[test]
fn cordic_reset_clears_all_state() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees::MIN, MicrosIsr(0));
t.update(Degrees(90.0), MicrosIsr(1000));
t.update(Degrees(180.0), MicrosIsr(1000));
t.reset();
assert_eq!(t.mode.revolutions, 0);
assert_eq!(t.mode.fractional_angle, Degrees::MIN);
assert!(t.mode.previous_angle.is_none());
assert!(t.angular_velocity_dps().is_none());
let delta = t.update(Degrees(45.0), MicrosIsr(1000));
assert!(delta.is_none());
}
#[test]
fn cordic_many_small_increments_no_drift() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees::MIN, MicrosIsr(0));
let mut angle = 0.0_f32;
for _ in 0..100_000 {
angle += 1.0;
if angle >= Degrees::MAX.0 {
angle -= Degrees::MAX.0;
}
t.update(Degrees(angle), MicrosIsr(100));
}
let expected_rev = 100_000.0 / Degrees::MAX.0;
let actual_rev = t.cumulative_revolutions();
let error = (actual_rev - expected_rev).abs();
assert!(
error < 0.01,
"expected ~{}, got {} (error {})",
expected_rev,
actual_rev,
error
);
}
#[test]
fn cordic_new_initializes_nan_sentinels() {
let t = RotationTracker::<2, Cordic>::new();
assert!(t.mode.previous_angle.is_none());
assert_eq!(t.mode.revolutions, 0);
assert_eq!(t.mode.fractional_angle, Degrees::MIN);
assert!(
t.mode.last_velocity_dps.is_nan(),
"last_velocity_dps must be NaN initially"
);
assert!(
t.mode.max_abs_delta.is_nan(),
"max_abs_delta must be NaN initially"
);
assert!(t.angular_velocity_dps().is_none());
assert!(t.max_abs_delta().is_none());
assert!(t.rpm().is_none());
}
#[test]
fn cordic_default_delegates_to_new() {
let from_default = RotationTracker::<2, Cordic>::default();
assert!(from_default.mode.last_velocity_dps.is_nan());
assert!(from_default.mode.max_abs_delta.is_nan());
assert!(from_default.angular_velocity_dps().is_none());
assert!(from_default.max_abs_delta().is_none());
assert!(from_default.rpm().is_none());
}
#[test]
fn cordic_max_abs_delta_sequential() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees::MIN, MicrosIsr(0));
t.update(Degrees(90.0), MicrosIsr(10_000));
t.update(Degrees(180.0), MicrosIsr(10_000));
let mad = t.max_abs_delta().unwrap();
assert!(
(mad - SignedDegrees(90.0)).abs() < SignedDegrees(0.01),
"expected 90.0, got {:?}",
mad
);
}
#[test]
fn cordic_max_abs_delta_reset_clears() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees::MIN, MicrosIsr(0));
t.update(Degrees(90.0), MicrosIsr(10_000));
assert!(t.max_abs_delta().is_some());
t.reset();
assert_eq!(t.max_abs_delta(), None);
}
#[test]
fn cordic_max_abs_delta_first_update_stays_zero() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees(45.0), MicrosIsr(1000));
assert_eq!(t.max_abs_delta(), None);
}
#[test]
fn cordic_max_abs_delta_wraparound_uses_shortest_path() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees(350.0), MicrosIsr(0));
t.update(Degrees(10.0), MicrosIsr(1000));
let mad = t.max_abs_delta().unwrap();
assert!(
(mad - SignedDegrees(20.0)).abs() < SignedDegrees(0.01),
"expected 20.0, got {:?}",
mad
);
}
#[test]
fn cordic_max_abs_delta_near_nyquist() {
let mut t = RotationTracker::<2, Cordic>::new();
t.update(Degrees::MIN, MicrosIsr(0));
t.update(Degrees(179.0), MicrosIsr(1000));
let mad = t.max_abs_delta().unwrap();
assert!(
(mad - SignedDegrees(179.0)).abs() < SignedDegrees(0.01),
"expected 179.0, got {:?}",
mad
);
}
#[test]
fn cordic_tracker_type_alias_works() {
let t = CordicTracker::new();
assert!(t.rpm().is_none());
}
#[test]
fn zc_schmitt_trigger_two_crossings_in_full_cycle() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(2.0));
let values = [5.0_f32, 3.0, -1.0, -5.0, -3.0, 1.0, 5.0];
for &v in &values {
t.update(mt(v), MicrosIsr(1000));
}
assert_eq!(
t.crossings(),
Crossings(2),
"expected 2 crossings from one full cycle, got {:?}",
t.crossings()
);
}
#[test]
fn zc_rpm_from_crossings_and_elapsed() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(0.5));
let mut state_positive = true;
t.update(
if state_positive { mt(5.0) } else { mt(-5.0) },
MicrosIsr(0),
);
for _ in 0..80 {
state_positive = !state_positive;
let elapsed = 300_000_u32 / 80; t.update(
if state_positive { mt(5.0) } else { mt(-5.0) },
MicrosIsr(elapsed),
);
}
let crossings = t.crossings();
assert_eq!(
crossings,
Crossings(80),
"expected 80 crossings, got {:?}",
crossings
);
let rpm = t.rpm().expect("rpm should be Some after crossings");
assert!(
(rpm.0 - 4000.0).abs() < 100.0,
"expected ~4000 RPM, got {}",
rpm.0
);
}
#[test]
fn zc_cumulative_revolutions() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(0.5));
let mut high = true;
t.update(mt(5.0), MicrosIsr(0));
for _ in 0..80 {
high = !high;
t.update(if high { mt(5.0) } else { mt(-5.0) }, MicrosIsr(1000));
}
let rev = t.cumulative_revolutions();
assert!(
(rev - 20.0).abs() < 0.01,
"expected 20.0 revolutions, got {}",
rev
);
}
#[test]
fn zc_reset_clears_state() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(0.5));
t.update(mt(5.0), MicrosIsr(0));
t.update(mt(-5.0), MicrosIsr(1000));
assert!(t.crossings() > Crossings(0));
t.reset();
assert_eq!(t.crossings(), Crossings(0));
assert_eq!(t.cumulative_revolutions(), 0.0);
assert!(t.rpm().is_none());
assert!(t.mode.state_high.is_none());
assert!((t.mode.hysteresis.0 - 0.5).abs() < 1e-6);
}
#[test]
fn zc_noise_within_band_counts_no_crossings() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(2.0));
t.update(mt(1.5), MicrosIsr(1000));
let noise = [1.0_f32, -1.0, 0.5, -0.5, 1.8, -1.8, 0.0];
for &v in &noise {
t.update(mt(v), MicrosIsr(1000));
}
assert_eq!(
t.crossings(),
Crossings(0),
"noise within dead band must not count crossings"
);
}
#[test]
fn zc_first_update_initializes_no_crossing() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(0.5));
t.update(mt(5.0), MicrosIsr(1000));
assert_eq!(
t.crossings(),
Crossings(0),
"first update must not count a crossing"
);
assert_eq!(t.mode.state_high, Some(true));
}
#[test]
fn zc_zero_hysteresis_simple_sign_change() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(0.0));
t.update(mt(1.0), MicrosIsr(1000)); t.update(mt(-1.0), MicrosIsr(1000)); t.update(mt(1.0), MicrosIsr(1000)); assert_eq!(
t.crossings(),
Crossings(2),
"H=0 should behave as sign-change detection"
);
}
#[test]
fn zc_zero_hysteresis_detects_zero_crossing_at_exact_zero() {
let mut t = RotationTracker::<2, ZeroCrossing>::new(MilliTesla(0.0));
t.update(mt(5.0), MicrosIsr(100)); t.update(mt(0.0), MicrosIsr(100)); assert_eq!(
t.crossings(),
Crossings(1),
"0.0 mT must trigger crossing with H=0"
);
t.update(mt(0.0), MicrosIsr(100)); assert_eq!(
t.crossings(),
Crossings(2),
"second 0.0 mT must trigger crossing back"
);
}
#[test]
fn zc_no_rpm_before_crossings() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(0.5));
assert!(t.rpm().is_none(), "rpm() must be None before any crossings");
t.update(mt(5.0), MicrosIsr(1000)); assert!(
t.rpm().is_none(),
"rpm() must be None after only first update"
);
}
#[test]
fn zc_max_abs_delta_returns_zero() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(0.5));
t.update(mt(5.0), MicrosIsr(1000));
t.update(mt(-5.0), MicrosIsr(1000));
assert_eq!(
t.max_abs_delta(),
None,
"ZeroCrossing max_abs_delta must always return None"
);
}
#[test]
fn zc_exposes_strong_pole_count() {
let t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(0.5));
assert_eq!(t.poles(), PoleCount::Four);
assert_eq!(u8::from(t.poles()), 4);
}
#[test]
fn zc_two_pole_works() {
let mut t = RotationTracker::<2, ZeroCrossing>::new(MilliTesla(0.5));
t.update(mt(5.0), MicrosIsr(0));
t.update(mt(-5.0), MicrosIsr(1000));
t.update(mt(5.0), MicrosIsr(1000));
assert_eq!(t.crossings(), Crossings(2));
assert!((t.cumulative_revolutions() - 1.0).abs() < 0.01);
}
#[test]
fn zc_three_pole_works() {
let mut t = RotationTracker::<3, ZeroCrossing>::new(MilliTesla(0.5));
t.update(mt(5.0), MicrosIsr(0));
t.update(mt(-5.0), MicrosIsr(1000));
t.update(mt(5.0), MicrosIsr(1000));
t.update(mt(-5.0), MicrosIsr(1000));
assert_eq!(t.crossings(), Crossings(3));
assert!((t.cumulative_revolutions() - 1.0).abs() < 0.01);
}
#[test]
fn zc_negative_hysteresis_clamped_to_zero() {
let t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(-1.0));
assert_eq!(
t.mode.hysteresis,
MilliTesla(0.0),
"negative H must clamp to MilliTesla(0.0)"
);
}
#[test]
fn zc_rpm_none_when_all_elapsed_zero() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(0.5));
t.update(mt(5.0), MicrosIsr(0)); t.update(mt(-5.0), MicrosIsr(0)); t.update(mt(5.0), MicrosIsr(0)); assert_eq!(t.crossings(), Crossings(2), "should have 2 crossings");
assert!(
t.rpm().is_none(),
"rpm() must be None when elapsed == MicrosIsr(0) (even with crossings > 0)"
);
}
#[test]
fn zc_elapsed_saturates_at_max() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(0.5));
t.update(mt(5.0), MicrosIsr(u32::MAX - 10));
t.update(mt(-5.0), MicrosIsr(20)); assert_eq!(
t.mode.elapsed,
MicrosIsr(u32::MAX),
"elapsed must saturate at MicrosIsr(u32::MAX), not wrap"
);
}
#[test]
fn zc_first_sample_in_dead_band_defers_init() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(0.8));
t.update(mt(0.3), MicrosIsr(1000));
assert!(
t.mode.state_high.is_none(),
"in-band first sample must not initialize state"
);
assert_eq!(t.crossings(), Crossings(0));
t.update(mt(-0.5), MicrosIsr(1000));
assert!(
t.mode.state_high.is_none(),
"in-band samples keep state as None"
);
assert_eq!(t.crossings(), Crossings(0));
t.update(mt(1.0), MicrosIsr(1000));
assert_eq!(t.mode.state_high, Some(true));
assert_eq!(
t.crossings(),
Crossings(0),
"initialization must not count a crossing"
);
t.update(mt(-1.0), MicrosIsr(1000));
assert_eq!(t.mode.state_high, Some(false));
assert_eq!(
t.crossings(),
Crossings(1),
"real crossing after deferred init"
);
}
#[test]
fn zc_exact_hysteresis_boundary_triggers_crossing() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(2.0));
t.update(mt(5.0), MicrosIsr(1000));
assert_eq!(t.mode.state_high, Some(true));
t.update(mt(-2.0), MicrosIsr(1000));
assert_eq!(
t.crossings(),
Crossings(1),
"exact -H must trigger HIGH→LOW crossing"
);
assert_eq!(t.mode.state_high, Some(false));
t.update(mt(2.0), MicrosIsr(1000));
assert_eq!(
t.crossings(),
Crossings(2),
"exact +H must trigger LOW→HIGH crossing"
);
assert_eq!(t.mode.state_high, Some(true));
}
#[test]
fn cordic_large_raw_delta_normalizes_to_shortest_path() {
let mut t = RotationTracker::<2, Cordic>::new();
let delta1 = t.update(Degrees(0.0), MicrosIsr(1000));
assert!(delta1.is_none(), "first update returns None (no previous)");
let delta2 = t.update(Degrees(350.0), MicrosIsr(1000));
let d = delta2.expect("second update should return Some");
assert!(
(d.0 - (-10.0)).abs() < 0.01,
"350° raw delta should normalize to -10° shortest path, got {}",
d.0
);
let frac = t.mode.fractional_angle.0;
assert!(
(frac - 350.0).abs() < 0.01,
"fractional angle should be 350° after -10° delta, got {}",
frac
);
}
#[test]
fn zc_ipi_first_crossing_returns_none() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(2.0));
assert!(t.update(mt(5.0), MicrosIsr(1000)).is_none()); let ipi = t.update(mt(-5.0), MicrosIsr(1000)); assert!(
ipi.is_none(),
"first crossing must return None (N-1 convention)"
);
assert_eq!(t.crossings(), Crossings(1));
}
#[test]
fn zc_ipi_second_crossing_returns_interval() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(2.0));
t.update(mt(5.0), MicrosIsr(0)); t.update(mt(-5.0), MicrosIsr(1000)); let ipi = t.update(mt(5.0), MicrosIsr(2000)); assert_eq!(
ipi,
Some(MicrosIsr(2000)),
"IPI should be elapsed since crossing #1"
);
}
#[test]
fn zc_ipi_accumulates_across_non_crossing_samples() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(2.0));
t.update(mt(5.0), MicrosIsr(0)); t.update(mt(-5.0), MicrosIsr(100)); t.update(mt(-3.0), MicrosIsr(200)); t.update(mt(-4.0), MicrosIsr(300)); let ipi = t.update(mt(5.0), MicrosIsr(400)); assert_eq!(
ipi,
Some(MicrosIsr(200 + 300 + 400)),
"IPI must include all elapsed since previous crossing"
);
}
#[test]
fn zc_ipi_multiple_crossings_emit_correct_intervals() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(2.0));
t.update(mt(5.0), MicrosIsr(0));
let ipi1 = t.update(mt(-5.0), MicrosIsr(100)); assert!(ipi1.is_none(), "first crossing → None");
let ipi2 = t.update(mt(5.0), MicrosIsr(200)); assert_eq!(ipi2, Some(MicrosIsr(200)));
let ipi3 = t.update(mt(-5.0), MicrosIsr(300)); assert_eq!(ipi3, Some(MicrosIsr(300)));
let ipi4 = t.update(mt(5.0), MicrosIsr(400)); assert_eq!(ipi4, Some(MicrosIsr(400)));
assert_eq!(t.crossings(), Crossings(4));
}
#[test]
fn zc_ipi_includes_triggering_sample_elapsed() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(2.0));
t.update(mt(5.0), MicrosIsr(0)); t.update(mt(-5.0), MicrosIsr(500)); let ipi = t.update(mt(5.0), MicrosIsr(750));
assert_eq!(ipi, Some(MicrosIsr(750)));
}
#[test]
fn zc_ipi_no_crossing_returns_none() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(2.0));
t.update(mt(5.0), MicrosIsr(1000)); assert!(t.update(mt(0.5), MicrosIsr(1000)).is_none());
assert!(t.update(mt(-0.5), MicrosIsr(1000)).is_none());
assert!(t.update(mt(1.0), MicrosIsr(1000)).is_none());
assert_eq!(t.crossings(), Crossings(0));
}
#[test]
fn zc_ipi_zero_elapsed_between_crossings() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(2.0));
t.update(mt(5.0), MicrosIsr(0)); t.update(mt(-5.0), MicrosIsr(0)); let ipi = t.update(mt(5.0), MicrosIsr(0)); assert_eq!(
ipi,
Some(MicrosIsr(0)),
"zero-elapsed IPI must not be suppressed"
);
}
#[test]
fn zc_ipi_saturates_at_u32_max() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(2.0));
t.update(mt(5.0), MicrosIsr(0)); t.update(mt(-5.0), MicrosIsr(100)); t.update(mt(0.0), MicrosIsr(u32::MAX - 10)); let ipi = t.update(mt(5.0), MicrosIsr(20)); assert_eq!(
ipi,
Some(MicrosIsr(u32::MAX)),
"IPI must saturate at u32::MAX, not wrap"
);
}
#[test]
fn zc_ipi_reset_makes_next_crossing_first() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(2.0));
t.update(mt(5.0), MicrosIsr(0));
t.update(mt(-5.0), MicrosIsr(100)); t.update(mt(5.0), MicrosIsr(200));
t.reset();
t.update(mt(5.0), MicrosIsr(0)); let ipi = t.update(mt(-5.0), MicrosIsr(500)); assert!(ipi.is_none(), "first crossing after reset must return None");
assert_eq!(t.crossings(), Crossings(1));
}
#[test]
fn zc_ipi_raw_u32_passthrough() {
let mut t = RotationTracker::<4, ZeroCrossing>::new(MilliTesla(2.0));
t.update(mt(5.0), MicrosIsr(0));
t.update(mt(-5.0), MicrosIsr(1000)); let ipi = t.update(mt(5.0), MicrosIsr(2500)); let raw: u32 = ipi.expect("crossing #2 should emit IPI").0;
assert_eq!(raw, 2500_u32);
}
#[test]
fn size_of_cordic_tracker_within_budget() {
assert!(
size_of::<RotationTracker<2, Cordic>>() <= 24,
"Cordic tracker size {} exceeds 24-byte budget",
size_of::<RotationTracker<2, Cordic>>()
);
}
#[test]
fn size_of_zero_crossing_tracker_within_budget() {
assert!(
size_of::<RotationTracker<4, ZeroCrossing>>() <= 24,
"ZeroCrossing tracker size {} exceeds 24-byte budget",
size_of::<RotationTracker<4, ZeroCrossing>>()
);
}
}