#[inline]
fn norm3(v: [f64; 3]) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
#[inline]
fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
#[inline]
fn scale3(v: [f64; 3], s: f64) -> [f64; 3] {
[v[0] * s, v[1] * s, v[2] * s]
}
#[inline]
fn normalize3(v: [f64; 3]) -> [f64; 3] {
let n = norm3(v);
if n < 1e-15 {
[0.0; 3]
} else {
scale3(v, 1.0 / n)
}
}
#[inline]
fn clamp(x: f64, lo: f64, hi: f64) -> f64 {
x.max(lo).min(hi)
}
#[derive(Debug, Clone)]
pub struct HillMuscle {
pub f_max: f64,
pub l_opt: f64,
pub v_max: f64,
pub pennation_angle: f64,
}
impl HillMuscle {
pub fn new(f_max: f64, l_opt: f64, v_max: f64, pennation_angle: f64) -> Self {
Self {
f_max,
l_opt,
v_max,
pennation_angle,
}
}
pub fn active_force_length(&self, l: f64) -> f64 {
let l_norm = l / self.l_opt;
let beta = 0.45;
let x = (l_norm - 1.0) / beta;
(-0.5 * x * x).exp().clamp(0.0, 1.0)
}
pub fn passive_force_length(&self, l: f64) -> f64 {
let l_norm = l / self.l_opt;
if l_norm <= 1.0 {
0.0
} else {
let kpe = 5.0; ((l_norm - 1.0) * kpe).exp() - 1.0
}
}
pub fn force_velocity(&self, v: f64) -> f64 {
if v <= 0.0 {
let a_hill = 0.25; let v_norm = v / self.v_max;
let num = 1.0 + v_norm;
let den = 1.0 - v_norm / a_hill;
if den.abs() < 1e-15 {
0.0
} else {
clamp(num / den, 0.0, 1.0)
}
} else {
let fv_max = 1.8;
let slope = (fv_max - 1.0) / self.v_max;
clamp(1.0 + slope * v, 1.0, fv_max)
}
}
pub fn total_force(&self, l: f64, v: f64, activation: f64) -> f64 {
let a = clamp(activation, 0.0, 1.0);
let fl = self.active_force_length(l);
let fv = self.force_velocity(v);
let fp = self.passive_force_length(l);
let cos_phi = self.pennation_angle.cos();
(a * fl * fv + fp) * self.f_max * cos_phi
}
}
#[derive(Debug, Clone)]
pub struct ActivationDynamics {
pub tau_act: f64,
pub tau_deact: f64,
}
impl ActivationDynamics {
pub fn new(tau_act: f64, tau_deact: f64) -> Self {
Self { tau_act, tau_deact }
}
pub fn step(&self, activation: f64, excitation: f64, dt: f64) -> f64 {
let a = clamp(activation, 0.0, 1.0);
let u = clamp(excitation, 0.0, 1.0);
let tau = if u > a {
self.tau_act * (0.5 + 1.5 * a)
} else {
self.tau_deact / (0.5 + 1.5 * a)
};
let da_dt = (u - a) / tau;
clamp(a + da_dt * dt, 0.0, 1.0)
}
}
#[derive(Debug, Clone)]
pub struct MuscleElement {
pub pos_a: [f64; 3],
pub pos_b: [f64; 3],
pub model: HillMuscle,
pub activation: f64,
pub rest_length: f64,
}
impl MuscleElement {
pub fn new(
pos_a: [f64; 3],
pos_b: [f64; 3],
model: HillMuscle,
activation: f64,
rest_length: f64,
) -> Self {
Self {
pos_a,
pos_b,
model,
activation,
rest_length,
}
}
pub fn current_length(&self) -> f64 {
norm3(sub3(self.pos_b, self.pos_a))
}
pub fn current_velocity(&self, prev_length: f64, dt: f64) -> f64 {
if dt < 1e-30 {
0.0
} else {
(self.current_length() - prev_length) / dt
}
}
pub fn force_vector(&self, dt: f64, prev_length: f64) -> ([f64; 3], [f64; 3]) {
let l = self.current_length();
let v = self.current_velocity(prev_length, dt);
let f_scalar = self.model.total_force(l, v, self.activation);
let ab = sub3(self.pos_b, self.pos_a);
let dir = normalize3(ab);
let force_on_b = scale3(dir, -f_scalar); let force_on_a = scale3(dir, f_scalar); (force_on_a, force_on_b)
}
}
#[derive(Debug, Clone)]
pub struct MuscleGroup {
pub elements: Vec<MuscleElement>,
pub pcsa: f64,
}
impl MuscleGroup {
pub fn new(elements: Vec<MuscleElement>, pcsa: f64) -> Self {
Self { elements, pcsa }
}
pub fn resultant_force(&self, dt: f64, prev_lengths: &[f64]) -> [f64; 3] {
let mut total = [0.0f64; 3];
for (elem, &pl) in self.elements.iter().zip(prev_lengths.iter()) {
let (fa, _fb) = elem.force_vector(dt, pl);
total[0] += fa[0];
total[1] += fa[1];
total[2] += fa[2];
}
total
}
pub fn set_activation(&mut self, activation: f64) {
let a = clamp(activation, 0.0, 1.0);
for elem in &mut self.elements {
elem.activation = a;
}
}
}
#[derive(Debug, Clone)]
pub struct ThilenFibreModel {
pub ce_length: f64,
pub see_stiffness: f64,
pub pee_stiffness: f64,
}
impl ThilenFibreModel {
pub fn new(ce_length: f64, see_stiffness: f64, pee_stiffness: f64) -> Self {
Self {
ce_length,
see_stiffness,
pee_stiffness,
}
}
pub fn equilibrium_length(&self, activation: f64, f_max: f64) -> f64 {
let a = clamp(activation, 0.0, 1.0);
let l0 = self.ce_length;
let l_tot = l0 * 1.1; let mut lo = 0.5 * l0;
let mut hi = 1.5 * l0;
for _ in 0..60 {
let mid = 0.5 * (lo + hi);
let l_norm = mid / l0;
let beta = 0.45;
let fl = (-(l_norm - 1.0).powi(2) / (2.0 * beta * beta)).exp();
let f_ce = a * fl * f_max + self.pee_stiffness * (mid - l0).max(0.0);
let f_see = self.see_stiffness * (l_tot - mid).max(0.0);
if f_ce < f_see {
hi = mid;
} else {
lo = mid;
}
}
0.5 * (lo + hi)
}
}
pub fn musculotendon_length(muscle_length: f64, tendon_length: f64, pennation: f64) -> f64 {
tendon_length + muscle_length * pennation.cos()
}
pub fn optimal_fiber_length_from_emg(emg: &[f64], force: &[f64]) -> f64 {
assert_eq!(
emg.len(),
force.len(),
"emg and force must have the same length"
);
if emg.is_empty() {
return 1.0;
}
let n_steps = 100;
let mut best_l_opt = 1.0f64;
let mut best_sse = f64::INFINITY;
for k in 0..=n_steps {
let l_opt = 0.5 + 1.5 * (k as f64 / n_steps as f64);
let mut sse = 0.0f64;
for (&u, &f) in emg.iter().zip(force.iter()) {
let beta = 0.45;
let x = (1.0 / l_opt - 1.0) / beta; let f_pred = u * (-0.5 * x * x).exp();
sse += (f - f_pred).powi(2);
}
if sse < best_sse {
best_sse = sse;
best_l_opt = l_opt;
}
}
best_l_opt
}
#[derive(Debug, Clone)]
pub struct MuscleFatigue {
pub fatigue_rate: f64,
pub recovery_rate: f64,
pub fatigue_factor: f64,
}
impl MuscleFatigue {
pub fn new(fatigue_rate: f64, recovery_rate: f64) -> Self {
Self {
fatigue_rate,
recovery_rate,
fatigue_factor: 0.0,
}
}
pub fn step(&mut self, activation: f64, dt: f64) {
let a = clamp(activation, 0.0, 1.0);
let d_fatigue = if a > self.fatigue_factor {
self.fatigue_rate * (a - self.fatigue_factor)
} else {
-self.recovery_rate * (self.fatigue_factor - a)
};
self.fatigue_factor = clamp(self.fatigue_factor + d_fatigue * dt, 0.0, 1.0);
}
pub fn available_force_fraction(&self) -> f64 {
1.0 - self.fatigue_factor
}
}
pub fn twitch_response(t: f64, t_rise: f64, t_fall: f64) -> f64 {
if t < 0.0 {
return 0.0;
}
let raw = (-t / t_fall).exp() - (-t / t_rise).exp();
let norm = if (t_rise - t_fall).abs() < 1e-15 || t_rise <= 0.0 || t_fall <= 0.0 {
1.0
} else {
let t_peak = (t_fall / t_rise).ln() / (1.0 / t_rise - 1.0 / t_fall);
let peak = (-t_peak / t_fall).exp() - (-t_peak / t_rise).exp();
if peak.abs() < 1e-30 { 1.0 } else { peak }
};
(raw / norm).max(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-9;
#[test]
fn test_fl_peaks_at_l_opt() {
let m = HillMuscle::new(1000.0, 0.1, 1.0, 0.0);
let fl_opt = m.active_force_length(m.l_opt);
assert!(
(fl_opt - 1.0).abs() < EPS,
"FL should be 1 at l_opt, got {fl_opt}"
);
}
#[test]
fn test_fl_decays_off_peak() {
let m = HillMuscle::new(1000.0, 0.1, 1.0, 0.0);
let fl_near = m.active_force_length(0.85 * m.l_opt);
assert!(fl_near < 1.0, "FL should be < 1 away from l_opt");
assert!(fl_near > 0.0, "FL should be positive near l_opt");
}
#[test]
fn test_fp_zero_at_l_opt() {
let m = HillMuscle::new(1000.0, 0.1, 1.0, 0.0);
assert_eq!(m.passive_force_length(m.l_opt), 0.0);
}
#[test]
fn test_fp_positive_above_l_opt() {
let m = HillMuscle::new(1000.0, 0.1, 1.0, 0.0);
let fp = m.passive_force_length(1.2 * m.l_opt);
assert!(
fp > 0.0,
"Passive force should be positive for l > l_opt, got {fp}"
);
}
#[test]
fn test_fv_at_zero() {
let m = HillMuscle::new(1000.0, 0.1, 1.0, 0.0);
let fv = m.force_velocity(0.0);
assert!((fv - 1.0).abs() < 1e-9, "FV at v=0 should be 1, got {fv}");
}
#[test]
fn test_fv_decreases_shortening() {
let m = HillMuscle::new(1000.0, 0.1, 1.0, 0.0);
let fv_neg = m.force_velocity(-0.3 * m.v_max);
assert!(fv_neg < 1.0, "FV should be < 1 during shortening");
assert!(fv_neg >= 0.0, "FV should be non-negative");
}
#[test]
fn test_fv_increases_lengthening() {
let m = HillMuscle::new(1000.0, 0.1, 1.0, 0.0);
let fv_pos = m.force_velocity(0.5 * m.v_max);
assert!(
fv_pos > 1.0,
"FV should be > 1 during lengthening, got {fv_pos}"
);
}
#[test]
fn test_total_force_zero_activation() {
let m = HillMuscle::new(1000.0, 0.1, 1.0, 0.0);
let f = m.total_force(m.l_opt, 0.0, 0.0);
assert_eq!(f, 0.0, "No force at zero activation and rest length");
}
#[test]
fn test_total_force_positive_activation() {
let m = HillMuscle::new(1000.0, 0.1, 1.0, 0.0);
let f = m.total_force(m.l_opt, 0.0, 1.0);
assert!(
(f - 1000.0).abs() < 1.0,
"Max force at full activation: {f}"
);
}
#[test]
fn test_total_force_pennation() {
let m0 = HillMuscle::new(1000.0, 0.1, 1.0, 0.0);
let m30 = HillMuscle::new(1000.0, 0.1, 1.0, 30.0_f64.to_radians());
let f0 = m0.total_force(m0.l_opt, 0.0, 1.0);
let f30 = m30.total_force(m30.l_opt, 0.0, 1.0);
assert!(f30 < f0, "Pennation reduces effective force");
}
#[test]
fn test_activation_increases() {
let dyn_ = ActivationDynamics::new(0.01, 0.04);
let a = dyn_.step(0.1, 1.0, 0.001);
assert!(a > 0.1, "Activation should increase toward excitation=1");
}
#[test]
fn test_activation_decreases() {
let dyn_ = ActivationDynamics::new(0.01, 0.04);
let a = dyn_.step(0.8, 0.0, 0.001);
assert!(
a < 0.8,
"Activation should decrease when excitation is zero"
);
}
#[test]
fn test_activation_clamped() {
let dyn_ = ActivationDynamics::new(0.01, 0.04);
let a = dyn_.step(0.0, 2.0, 1.0); assert!(
(0.0..=1.0).contains(&a),
"Activation must stay in [0, 1]: {a}"
);
}
#[test]
fn test_muscle_element_length() {
let model = HillMuscle::new(500.0, 0.08, 0.8, 0.0);
let elem = MuscleElement::new([0.0, 0.0, 0.0], [0.0, 0.1, 0.0], model, 0.5, 0.1);
assert!((elem.current_length() - 0.1).abs() < EPS);
}
#[test]
fn test_muscle_element_velocity() {
let model = HillMuscle::new(500.0, 0.08, 0.8, 0.0);
let elem = MuscleElement::new([0.0, 0.0, 0.0], [0.0, 0.12, 0.0], model, 0.5, 0.1);
let v = elem.current_velocity(0.1, 0.01);
assert!((v - 2.0).abs() < 1e-9, "Velocity should be 2 m/s, got {v}");
}
#[test]
fn test_muscle_element_force_newton3() {
let model = HillMuscle::new(1000.0, 0.1, 1.0, 0.0);
let elem = MuscleElement::new([0.0, 0.0, 0.0], [0.0, 0.1, 0.0], model, 1.0, 0.1);
let (fa, fb) = elem.force_vector(0.01, 0.1);
assert!(
(fa[0] + fb[0]).abs() < EPS
&& (fa[1] + fb[1]).abs() < EPS
&& (fa[2] + fb[2]).abs() < EPS,
"Forces on A and B should sum to zero (Newton 3rd law)"
);
}
#[test]
fn test_muscle_group_resultant() {
let model = HillMuscle::new(500.0, 0.1, 1.0, 0.0);
let e1 = MuscleElement::new([0.0, 0.0, 0.0], [0.0, 0.1, 0.0], model.clone(), 1.0, 0.1);
let e2 = MuscleElement::new([0.0, 0.0, 0.0], [0.0, 0.1, 0.0], model.clone(), 1.0, 0.1);
let group = MuscleGroup::new(vec![e1, e2], 1e-4);
let f = group.resultant_force(0.01, &[0.1, 0.1]);
let single = MuscleElement::new([0.0, 0.0, 0.0], [0.0, 0.1, 0.0], model, 1.0, 0.1);
let (fa, _) = single.force_vector(0.01, 0.1);
assert!(
(f[1] - 2.0 * fa[1]).abs() < EPS,
"Group force should be 2× single, got {} vs {}",
f[1],
2.0 * fa[1]
);
}
#[test]
fn test_muscle_group_set_activation() {
let model = HillMuscle::new(500.0, 0.1, 1.0, 0.0);
let e = MuscleElement::new([0.0; 3], [0.0, 0.1, 0.0], model, 0.5, 0.1);
let mut group = MuscleGroup::new(vec![e], 1e-4);
group.set_activation(1.5); assert_eq!(group.elements[0].activation, 1.0);
}
#[test]
fn test_thilen_equilibrium_range() {
let model = ThilenFibreModel::new(0.1, 50_000.0, 5_000.0);
let l_eq = model.equilibrium_length(0.5, 1000.0);
assert!(
l_eq > 0.0 && l_eq < 1.0,
"Equilibrium length should be in (0, 1), got {l_eq}"
);
}
#[test]
fn test_thilen_activation_effect() {
let model = ThilenFibreModel::new(0.1, 50_000.0, 5_000.0);
let l_low = model.equilibrium_length(0.1, 1000.0);
let l_high = model.equilibrium_length(0.9, 1000.0);
assert_ne!(
l_low, l_high,
"Equilibrium length should change with activation"
);
}
#[test]
fn test_mtu_length_zero_pennation() {
let l = musculotendon_length(0.08, 0.25, 0.0);
assert!(
(l - 0.33).abs() < EPS,
"MTU length with zero pennation: {l}"
);
}
#[test]
fn test_mtu_length_perpendicular() {
let l = musculotendon_length(0.08, 0.25, std::f64::consts::FRAC_PI_2);
assert!((l - 0.25).abs() < 1e-9, "MTU length at 90° pennation: {l}");
}
#[test]
fn test_emg_fiber_length_range() {
let emg = vec![0.2, 0.5, 0.8, 1.0, 0.6];
let force = vec![200.0, 500.0, 800.0, 1000.0, 600.0];
let l_opt = optimal_fiber_length_from_emg(&emg, &force);
assert!((0.5..=2.0).contains(&l_opt), "l_opt out of range: {l_opt}");
}
#[test]
fn test_emg_empty() {
let l = optimal_fiber_length_from_emg(&[], &[]);
assert_eq!(l, 1.0);
}
#[test]
fn test_fatigue_starts_zero() {
let f = MuscleFatigue::new(0.01, 0.005);
assert_eq!(f.fatigue_factor, 0.0);
}
#[test]
fn test_fatigue_increases() {
let mut f = MuscleFatigue::new(0.1, 0.01);
for _ in 0..100 {
f.step(1.0, 0.01);
}
assert!(
f.fatigue_factor > 0.0,
"Fatigue should increase under sustained activation"
);
}
#[test]
fn test_fatigue_force_fraction() {
let mut f = MuscleFatigue::new(0.1, 0.01);
for _ in 0..100 {
f.step(1.0, 0.01);
}
assert!(
f.available_force_fraction() < 1.0,
"Force fraction should decrease under fatigue"
);
}
#[test]
fn test_fatigue_clamped() {
let mut f = MuscleFatigue::new(1.0, 0.0001);
for _ in 0..10_000 {
f.step(1.0, 0.1);
}
assert!(
f.fatigue_factor >= 0.0 && f.fatigue_factor <= 1.0,
"Fatigue factor out of range: {}",
f.fatigue_factor
);
}
#[test]
fn test_twitch_negative_time() {
assert_eq!(twitch_response(-0.001, 0.02, 0.08), 0.0);
}
#[test]
fn test_twitch_positive() {
let v = twitch_response(0.05, 0.02, 0.08);
assert!(v > 0.0, "Twitch should be positive at t=0.05, got {v}");
}
#[test]
fn test_twitch_decays() {
let v = twitch_response(10.0, 0.02, 0.08);
assert!(v < 1e-3, "Twitch should decay for large t, got {v}");
}
#[test]
fn test_twitch_peak_near_one() {
let t_rise = 0.02;
let t_fall = 0.08;
let peak = (0..1000)
.map(|i| twitch_response(i as f64 * 0.001, t_rise, t_fall))
.fold(0.0_f64, f64::max);
assert!(
(peak - 1.0).abs() < 0.05,
"Twitch peak should be near 1, got {peak}"
);
}
#[test]
fn test_fl_symmetric() {
let m = HillMuscle::new(1000.0, 0.1, 1.0, 0.0);
let d = 0.01;
let fl_above = m.active_force_length(m.l_opt + d);
let fl_below = m.active_force_length(m.l_opt - d);
assert!(
(fl_above - fl_below).abs() < 1e-9,
"FL curve should be symmetric: fl_above={fl_above}, fl_below={fl_below}"
);
}
#[test]
fn test_fv_at_v_max_shortening() {
let m = HillMuscle::new(1000.0, 0.1, 1.0, 0.0);
let fv = m.force_velocity(-m.v_max);
assert!(
fv.abs() < 1e-9,
"FV should be 0 at max shortening, got {fv}"
);
}
#[test]
fn test_fatigue_recovers() {
let mut f = MuscleFatigue::new(0.5, 0.5);
f.fatigue_factor = 0.8;
for _ in 0..100 {
f.step(0.0, 0.01);
}
assert!(
f.fatigue_factor < 0.8,
"Fatigue should decrease during rest, got {}",
f.fatigue_factor
);
}
#[test]
fn test_muscle_group_empty() {
let group = MuscleGroup::new(vec![], 1e-4);
let f = group.resultant_force(0.01, &[]);
assert_eq!(f, [0.0, 0.0, 0.0]);
}
#[test]
fn test_activation_steady_state() {
let dyn_ = ActivationDynamics::new(0.01, 0.04);
let a_init = 0.5;
let a_new = dyn_.step(a_init, a_init, 0.001);
assert!(
(a_new - a_init).abs() < 1e-6,
"No change expected at steady state, got {a_new}"
);
}
#[test]
fn test_total_force_full_pennation() {
let m = HillMuscle::new(1000.0, 0.1, 1.0, std::f64::consts::FRAC_PI_2);
let f = m.total_force(m.l_opt, 0.0, 1.0);
assert!(
f.abs() < 1e-9,
"90° pennation gives zero projected force, got {f}"
);
}
#[test]
fn test_muscle_element_zero_dt() {
let model = HillMuscle::new(500.0, 0.1, 1.0, 0.0);
let elem = MuscleElement::new([0.0; 3], [0.0, 0.1, 0.0], model, 0.5, 0.1);
let v = elem.current_velocity(0.09, 0.0);
assert_eq!(v, 0.0, "Zero dt should give zero velocity");
}
#[test]
fn test_mtu_length_increases() {
let l1 = musculotendon_length(0.05, 0.2, 0.2);
let l2 = musculotendon_length(0.10, 0.2, 0.2);
assert!(l2 > l1, "Longer muscle → longer MTU");
}
}