const MODEL_MASS_KG: f32 = 113.0;
pub const DEFAULT_CAB_WEIGHT_KG: f32 = 113.0;
const OSC_X: (f32, f32) = (4.5, 0.04);
const OSC_Y: (f32, f32) = (4.3, 0.055);
const DT: f32 = 0.001;
#[derive(Debug, Clone, Copy)]
struct Oscillator {
k: f32,
damping: f32,
velocity: f32,
displacement: f32,
acceleration: f32,
}
impl Oscillator {
fn new(freq: f32, zeta: f32) -> Self {
let omega0 = std::f32::consts::TAU * freq;
Self {
k: MODEL_MASS_KG * omega0 * omega0,
damping: 2.0 * zeta * (MODEL_MASS_KG * omega0),
velocity: 0.0,
displacement: 0.0,
acceleration: 0.0,
}
}
fn step(&mut self, force: f32) {
self.acceleration =
(force - self.damping * self.velocity - self.k * self.displacement) / MODEL_MASS_KG;
self.velocity += self.acceleration * DT;
self.displacement += self.velocity * DT;
}
}
#[derive(Debug, Clone, Copy)]
struct IntentDetector {
time: i32,
segment_is_peak: bool,
segment_strength: f32,
segment_start: i32,
segment_end: i32,
segment_impulse_sent: bool,
last_impulse_time: i32,
last_impulse_strength: f32,
impulse: (f32, f32),
impulse_elapsed: i32,
impulse_delay: i32,
}
const IMPULSE_LENGTH: i32 = 25;
const IMPULSE_THRESHOLD: f32 = 1.0;
pub const INTENT_THRESHOLD_MS2: f32 = IMPULSE_THRESHOLD;
const NO_DELAY_STRENGTH: f32 = 0.7 * 9.80665;
const SENSOR_POLL_MS: f32 = 8.0;
impl Default for IntentDetector {
fn default() -> Self {
Self {
time: 0,
segment_is_peak: false,
segment_strength: 0.0,
segment_start: 0,
segment_end: 0,
segment_impulse_sent: false,
last_impulse_time: 0,
last_impulse_strength: 0.0,
impulse: (0.0, 0.0),
impulse_elapsed: IMPULSE_LENGTH + 1,
impulse_delay: 0,
}
}
}
impl IntentDetector {
fn impulse_in_progress(&self) -> bool {
self.impulse_elapsed <= self.impulse_length_with_delay()
}
fn impulse_length_with_delay(&self) -> i32 {
IMPULSE_LENGTH + self.impulse_delay
}
fn impulse_acceleration(&self) -> (f32, f32) {
if !self.impulse_in_progress() || self.impulse_elapsed < self.impulse_delay {
return (0.0, 0.0);
}
let t = (self.impulse_elapsed - self.impulse_delay) as f32 / IMPULSE_LENGTH as f32;
let shape = 0.5 * (1.0 - (std::f32::consts::TAU * t).cos());
(self.impulse.0 * shape, self.impulse.1 * shape)
}
fn impulse_delay_for(strength: f32) -> i32 {
let firmness = (strength / NO_DELAY_STRENGTH).clamp(0.0, 1.0);
(3.0 * SENSOR_POLL_MS + (SENSOR_POLL_MS - 3.0 * SENSOR_POLL_MS) * firmness) as i32
}
fn evaluate_impulse(&mut self, nudge: (f32, f32)) {
if self.segment_strength <= self.last_impulse_strength
&& self.segment_end - self.last_impulse_time <= 300
{
return;
}
if self.segment_strength > IMPULSE_THRESHOLD {
self.impulse = nudge;
self.impulse_elapsed = 0;
self.impulse_delay = Self::impulse_delay_for(self.segment_strength);
self.segment_impulse_sent = true;
}
}
fn step(&mut self, accel: (f32, f32)) {
self.impulse_elapsed += 1;
self.time += 1;
let nudge = (accel.0, accel.1.min(0.0));
let strength = (nudge.0 * nudge.0 + nudge.1 * nudge.1).sqrt();
if self.segment_is_peak {
if strength > self.segment_strength {
self.segment_strength = strength;
self.segment_end = self.time;
if !self.segment_impulse_sent {
self.evaluate_impulse(nudge);
} else if self.impulse_in_progress() {
let new_strength_sqr = nudge.0 * nudge.0 + nudge.1 * nudge.1;
let current_sqr =
self.impulse.0 * self.impulse.0 + self.impulse.1 * self.impulse.1;
if new_strength_sqr > current_sqr {
self.impulse = nudge;
if self.impulse_elapsed < self.impulse_delay {
self.impulse_delay = self
.impulse_elapsed
.max(Self::impulse_delay_for(new_strength_sqr.sqrt()));
}
}
}
} else if strength < self.segment_strength * 0.9 {
self.last_impulse_time = self.segment_end;
self.last_impulse_strength = self.segment_strength;
self.segment_strength = strength;
self.segment_start = self.time;
self.segment_end = self.time;
self.segment_is_peak = false;
}
} else if strength < self.segment_strength {
self.segment_strength = strength;
self.segment_end = self.time;
} else if strength > 0.1_f32.max(self.segment_strength * 1.1) {
self.segment_strength = strength;
self.segment_start = self.time;
self.segment_end = self.time;
self.segment_is_peak = true;
self.segment_impulse_sent = false;
self.evaluate_impulse(nudge);
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct NudgeSim {
osc_x: Oscillator,
osc_y: Oscillator,
intent: IntentDetector,
kalman_x: crate::motion_kalman::MotionKalmanAxis,
kalman_y: crate::motion_kalman::MotionKalmanAxis,
rest_count: (i32, i32),
ema: (f32, f32),
cabinet_acceleration: (f32, f32),
}
impl Default for NudgeSim {
fn default() -> Self {
Self {
osc_x: Oscillator::new(OSC_X.0, OSC_X.1),
osc_y: Oscillator::new(OSC_Y.0, OSC_Y.1),
intent: IntentDetector::default(),
kalman_x: crate::motion_kalman::MotionKalmanAxis::default(),
kalman_y: crate::motion_kalman::MotionKalmanAxis::default(),
rest_count: (0, 0),
ema: (0.0, 0.0),
cabinet_acceleration: (0.0, 0.0),
}
}
}
impl NudgeSim {
pub fn step(&mut self, accel: (f32, f32), strength: f32, cab_weight: f32, sensor_type: i32) {
const REST_THRESHOLD_MS2: f32 = 0.1;
const REST_SAMPLES: i32 = 275;
self.kalman_x.predict(DT);
self.kalman_y.predict(DT);
self.kalman_x.update_acceleration(accel.0);
self.kalman_y.update_acceleration(accel.1);
self.rest_count.0 = if accel.0.abs() < REST_THRESHOLD_MS2 {
self.rest_count.0 + 1
} else {
0
};
self.rest_count.1 = if accel.1.abs() < REST_THRESHOLD_MS2 {
self.rest_count.1 + 1
} else {
0
};
if self.rest_count.0 > REST_SAMPLES {
self.kalman_x.update_rest();
}
if self.rest_count.1 > REST_SAMPLES {
self.kalman_y.update_rest();
}
let accel = (self.kalman_x.acceleration(), self.kalman_y.acceleration());
match sensor_type {
2 => {
let alpha = DT / 0.004; self.ema.0 += (accel.0 - self.ema.0) * alpha.min(1.0);
self.ema.1 += (accel.1 - self.ema.1) * alpha.min(1.0);
let mass_ratio = cab_weight / MODEL_MASS_KG;
let a = (
self.ema.0 * strength * mass_ratio,
self.ema.1 * strength * mass_ratio,
);
self.osc_x.step(MODEL_MASS_KG * a.0);
self.osc_y.step(MODEL_MASS_KG * a.1);
}
_ => {
self.intent
.step((accel.0 * strength * (4.0 / 3.0), accel.1 * strength));
let impulse = self.intent.impulse_acceleration();
if self.intent.impulse_in_progress() {
self.osc_x.step(MODEL_MASS_KG * impulse.0);
self.osc_y.step(MODEL_MASS_KG * impulse.1);
} else {
self.osc_x.step(0.0);
self.osc_y.step(0.0);
}
}
}
self.cabinet_acceleration = (self.osc_x.acceleration, self.osc_y.acceleration);
}
pub fn cabinet_acceleration(&self) -> (f32, f32) {
self.cabinet_acceleration
}
pub fn impulse_active(&self) -> bool {
self.intent.impulse_in_progress()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn knock(sim: &mut NudgeSim, accel: f32, ms: usize, sensor_type: i32) -> f32 {
let mut peak: f32 = 0.0;
for i in 0..600 {
let a = if i < ms { (accel, 0.0) } else { (0.0, 0.0) };
sim.step(a, 1.0, 113.0, sensor_type);
peak = peak.max(sim.cabinet_acceleration().0.abs());
}
peak
}
#[test]
fn intent_ignores_what_stays_under_the_threshold() {
let mut sim = NudgeSim::default();
let peak = knock(&mut sim, 0.5, 20, 1);
assert!(peak < 0.01, "leaked {peak} m/s²");
}
#[test]
fn intent_answers_a_firm_knock() {
let mut sim = NudgeSim::default();
let peak = knock(&mut sim, 6.0, 20, 1);
assert!(peak > 0.5, "only got {peak} m/s²");
}
#[test]
fn cabinet_mode_passes_small_shakes_through() {
let mut sim = NudgeSim::default();
let peak = knock(&mut sim, 0.5, 20, 2);
assert!(peak > 0.01, "cabinet mode swallowed it: {peak}");
}
#[test]
fn the_cabinet_rings_and_settles() {
let mut sim = NudgeSim::default();
knock(&mut sim, 6.0, 20, 2);
for _ in 0..2000 {
sim.step((0.0, 0.0), 1.0, 113.0, 2);
}
let resting = sim.cabinet_acceleration().0.abs();
assert!(resting < 0.05, "still moving at {resting} m/s²");
}
}
pub fn peak_tilt_angle(
sensor_accel: f32,
sensor_type: i32,
strength: f32,
cab_weight: f32,
damping: f32,
) -> f32 {
const UNREACHABLE_DEG: f32 = 90.0;
const SETTLE_MS: usize = 400;
const DURATION_MS: usize = SETTLE_MS + 1500;
const SHOVE_MS: usize = SETTLE_MS + 100;
let mut sim = NudgeSim::default();
let mut plumb = crate::plumb::Plumb::default();
let mut peak = 0.0_f32;
for i in 0..DURATION_MS {
let accel = if (SETTLE_MS..SHOVE_MS).contains(&i) {
(sensor_accel, 0.0)
} else {
(0.0, 0.0)
};
sim.step(accel, strength, cab_weight, sensor_type);
plumb.step(sim.cabinet_acceleration(), damping, UNREACHABLE_DEG);
let angle = plumb.angle_deg();
if !angle.is_finite() {
return 180.0;
}
peak = peak.max(angle);
}
peak
}
pub fn sensor_accel_for_tilt(
threshold_deg: f32,
sensor_type: i32,
strength: f32,
cab_weight: f32,
damping: f32,
) -> f32 {
let reaches =
|a: f32| peak_tilt_angle(a, sensor_type, strength, cab_weight, damping) >= threshold_deg;
let mut lo = 0.0_f32;
let mut hi = f32::INFINITY;
let mut probe = 0.05_f32;
while probe < 100.0 {
if reaches(probe) {
hi = probe;
break;
}
lo = probe;
probe *= 1.2;
}
if !hi.is_finite() {
return f32::INFINITY;
}
for _ in 0..14 {
let mid = 0.5 * (lo + hi);
if reaches(mid) { hi = mid } else { lo = mid }
}
0.5 * (lo + hi)
}
#[cfg(test)]
mod settle_tests {
use super::*;
#[test]
fn a_hard_shove_from_a_cold_start_never_reaches_the_cabinet() {
let cold = {
let mut sim = NudgeSim::default();
let mut peak = 0.0_f32;
for i in 0..1500 {
let a = if i < 100 { (70.0, 0.0) } else { (0.0, 0.0) };
sim.step(a, 1.0, DEFAULT_CAB_WEIGHT_KG, 1);
peak = peak.max(sim.cabinet_acceleration().0.abs());
}
peak
};
assert!(
cold < 5.0,
"a 70 m/s² shove read as bias should barely move the cabinet, got {cold}"
);
let settled = peak_tilt_angle(70.0, 1, 1.0, DEFAULT_CAB_WEIGHT_KG, 1.0);
assert!(settled > 4.0, "a settled shove should tilt, got {settled}");
}
#[test]
fn peak_angle_grows_with_the_shove() {
for mode in [1, 2] {
let mut previous = 0.0;
for accel in [1.0_f32, 2.5, 5.0, 10.0, 20.0, 40.0, 78.5] {
let angle = peak_tilt_angle(accel, mode, 1.0, DEFAULT_CAB_WEIGHT_KG, 1.0);
assert!(
angle >= previous,
"mode {mode}: {accel} m/s² gave {angle}°, below the {previous}° of a softer shove"
);
previous = angle;
}
}
}
#[test]
fn strength_scales_the_shove_needed_inversely() {
let at_one = sensor_accel_for_tilt(1.1125, 1, 1.0, DEFAULT_CAB_WEIGHT_KG, 1.0);
let at_two = sensor_accel_for_tilt(1.1125, 1, 2.0, DEFAULT_CAB_WEIGHT_KG, 1.0);
let ratio = at_one / at_two;
assert!((ratio - 2.0).abs() < 0.15, "expected ~2x, got {ratio}");
}
}
#[cfg(test)]
mod bench {
use super::*;
#[test]
#[ignore]
fn tilt_thresholds_in_sensor_acceleration() {
for (mode, name) in [(1, "intent"), (2, "cabinet")] {
println!("\n=== {name} (strength 1.0, damping 1.0) ===");
println!(" angle m/s² g % of ±1g % of ±2g");
for deg in [0.15_f32, 1.0, 1.1125, 2.075, 4.0] {
let a = sensor_accel_for_tilt(deg, mode, 1.0, DEFAULT_CAB_WEIGHT_KG, 1.0);
println!(
" {deg:5.2}° {a:7.2} {:6.2} {:9.0} % {:9.0} %",
a / 9.80665,
100.0 * a / 9.80665,
100.0 * a / (2.0 * 9.80665)
);
}
}
println!("\n=== intent, effect of strength on the 1.11° ring ===");
for s in [0.5_f32, 1.0, 1.5, 2.0] {
let a = sensor_accel_for_tilt(1.1125, 1, s, DEFAULT_CAB_WEIGHT_KG, 1.0);
println!(
" strength {s:.1} → {a:6.2} m/s² = {:3.0} % of ±1g",
100.0 * a / 9.80665
);
}
}
#[test]
#[ignore]
fn reachable_tilt_range_per_sensor_range() {
const G: f32 = 9.80665;
for (mode, name) in [(1, "intent"), (2, "cabinet")] {
println!("\n=== {name} ===");
for range in [1.0_f32, 2.0, 4.0, 8.0] {
let full = range * G;
let dead = 0.03 * full;
let floor = if mode == 1 {
dead.max(INTENT_THRESHOLD_MS2)
} else {
dead
};
let ceiling =
peak_tilt_angle(full, mode, 1.0, DEFAULT_CAB_WEIGHT_KG, 1.0).clamp(0.15, 4.0);
let bottom =
peak_tilt_angle(floor, mode, 1.0, DEFAULT_CAB_WEIGHT_KG, 1.0).clamp(0.15, 4.0);
println!(
" ±{range:.0} g full scale {full:5.1} m/s² min tilt {bottom:.2}° max tilt {ceiling:.2}°"
);
}
}
}
}