Source code for myogen.simulator.neuron.muscle

"""
Hill Muscle Model API Wrapper

This module provides a clean API wrapper for the Hill muscle model,
allowing for intuitive parameter names while maintaining compatibility
with the underlying Hill implementation.
"""

from typing import Any, Dict, Literal

import numpy as np

from myogen.simulator.neuron._cython._hill import _HillMuscleModel__Cython
from myogen.utils.decorators import beartowertype
from myogen.utils.types import Quantity__ms


[docs] @beartowertype class HillModel: """ API wrapper for the Hill muscle model. This class provides an intuitive interface for creating Hill muscle models with user-friendly parameter names that are internally mapped to the correct format expected by the underlying Hill implementation. Parameters ---------- simulation_time__ms : float Total simulation time in milliseconds time_step__ms : float Integration time step in milliseconds muscle_parameters : Dict[str, Any] Dictionary containing Hill muscle model parameters n_motor_units_type1 : int Number of type I motor units n_motor_units_type2 : int Number of type II motor units initial_joint_angle__deg : float Initial joint angle in degrees initial_muscle_length__L0 : float, optional Initial muscle length normalized to L0. If -1, automatically calculated from joint angle. Must be between 0.7 and 1.3 if specified. muscle_role : str, optional Muscle role for antagonist pairs ("flexor" or "extensor"), by default "flexor". Used for joint dynamics calculations and result organization. """
[docs] @beartowertype def __init__( self, simulation_time__ms: Quantity__ms, time_step__ms: Quantity__ms, muscle_parameters: Dict[str, Any], n_motor_units_type1: int, n_motor_units_type2: int, initial_joint_angle__deg: float, initial_muscle_length__L0: float = -1.0, muscle_role: Literal["flexor", "extensor"] = "flexor", ): # Store original parameters (immutable) self.simulation_time__ms = simulation_time__ms self.time_step__ms = time_step__ms self.muscle_parameters = muscle_parameters.copy() self.n_motor_units_type1 = n_motor_units_type1 self.n_motor_units_type2 = n_motor_units_type2 self.initial_joint_angle__deg = initial_joint_angle__deg self.initial_muscle_length__L0 = initial_muscle_length__L0 self.muscle_role = muscle_role # Private working copies for internal use self._simulation_time__ms = simulation_time__ms self._time_step__ms = time_step__ms self._muscle_parameters = muscle_parameters.copy() self._n_motor_units_type1 = n_motor_units_type1 self._n_motor_units_type2 = n_motor_units_type2 self._initial_joint_angle__deg = initial_joint_angle__deg self._initial_muscle_length__L0 = initial_muscle_length__L0 self._muscle_role = muscle_role # Validate inputs self._validate_parameters() # Create the underlying Hill model self._hill_model = self._create_hill_model()
def _validate_parameters(self) -> None: """Validate input parameters.""" if self._simulation_time__ms <= 0: raise ValueError("simulation_time__ms must be positive") if self._time_step__ms <= 0: raise ValueError("time_step__ms must be positive") if self._simulation_time__ms <= self._time_step__ms: raise ValueError("simulation_time__ms must be greater than time_step__ms") if self._n_motor_units_type1 < 0: raise ValueError("n_motor_units_type1 must be non-negative") if self._n_motor_units_type2 < 0: raise ValueError("n_motor_units_type2 must be non-negative") if self._initial_muscle_length__L0 != -1.0 and ( self._initial_muscle_length__L0 < 0.7 or self._initial_muscle_length__L0 > 1.3 ): raise ValueError("initial_muscle_length__L0 must be -1 or between 0.7 and 1.3") if self._muscle_role not in ["flexor", "extensor"]: raise ValueError("muscle_role must be 'flexor' or 'extensor'") def _create_hill_model(self) -> _HillMuscleModel__Cython: """ Create the underlying Hill model instance. This method maps the user-friendly parameter names to the format expected by the Hill constructor. """ # Create Hill model with mapped parameters return _HillMuscleModel__Cython( tstop__ms=self._simulation_time__ms, dt__ms=self._time_step__ms, hillD=self._muscle_parameters, Ntype1=self._n_motor_units_type1, Ntype2=self._n_motor_units_type2, artAng0=np.radians(self._initial_joint_angle__deg), # Convert to radians L0=self._initial_muscle_length__L0, )
[docs] def add_spike(self, motor_unit_id: int, delay__ms: float = 0.0) -> None: """ Add a spike event for a specific motor unit. Parameters ---------- motor_unit_id : int ID of the motor unit (0-based index) delay__ms : float, optional Spike delay in milliseconds, by default 0.0 """ self._hill_model.addSpike(motor_unit_id, delay__ms)
[docs] def integrate(self, joint_angle__deg: float) -> tuple[float, float, float]: """ Integrate the muscle model for one time step. Parameters ---------- joint_angle__deg : float Current joint angle in degrees Returns ------- tuple[float, float, float] Muscle length (normalized to L0), velocity (L0/s), acceleration (L0/s^2) """ return self._hill_model.integrate(np.radians(joint_angle__deg))
@property def muscle_length(self) -> np.ndarray: """Get muscle length time series (normalized to L0).""" return np.asarray(self._hill_model.L) @property def muscle_velocity(self) -> np.ndarray: """Get muscle velocity time series (L0/s).""" return np.asarray(self._hill_model.V) @property def muscle_acceleration(self) -> np.ndarray: """Get muscle acceleration time series (L0/s^2).""" return np.asarray(self._hill_model.A) @property def muscle_force(self) -> np.ndarray: """Get muscle force time series (normalized to F0).""" return np.asarray(self._hill_model.force) @property def muscle_torque(self) -> np.ndarray: """Get muscle torque time series (F0*m).""" return np.asarray(self._hill_model.torque) @property def signed_muscle_torque(self) -> np.ndarray: """Get muscle torque with correct sign for joint dynamics (F0*m).""" torque = np.asarray(self._hill_model.torque) # Extensor muscles produce negative torque (opposing flexion) return -torque if self._muscle_role == "extensor" else torque @property def type1_activation(self) -> np.ndarray: """Get Type I motor unit activation time series.""" return np.asarray(self._hill_model.F1) @property def type2_activation(self) -> np.ndarray: """Get Type II motor unit activation time series.""" return np.asarray(self._hill_model.F2) @property def motor_unit_forces(self) -> np.ndarray: """Get individual motor unit forces matrix (N_units x time_points).""" return np.asarray(self._hill_model.f) @property def time_vector(self) -> np.ndarray: """Get simulation time vector in milliseconds.""" return np.asarray(self._hill_model.time) @property def F0(self) -> float: """Get maximum isometric force (F0) in Newtons.""" return self._hill_model.F0 @property def L0(self) -> float: """Get optimal muscle length (L0) in meters.""" return self._hill_model.L0 def __repr__(self) -> str: """String representation of the Hill muscle model.""" return ( f"HillMuscleModel(" f"role={self.muscle_role}, " f"t_sim={self.simulation_time__ms}ms, " f"dt={self.time_step__ms}ms, " f"n_MU_I={self.n_motor_units_type1}, " f"n_MU_II={self.n_motor_units_type2}, " f"F0={self.F0:.1f}N, " f"L0={self.L0 * 1000:.1f}mm)" ) # Convenience function for creating default muscle parameter dictionaries
[docs] @staticmethod def create_default_muscle_parameters(muscle_type: str = "FDI") -> Dict[str, Any]: """ Create default muscle parameter dictionary. Parameters ---------- muscle_type : str, optional Type of muscle model ("FDI", "Sol"), by default "FDI" Returns ------- Dict[str, Any] Dictionary of muscle parameters Raises ------ ValueError If muscle_type is not recognized """ if muscle_type == "FDI": return { # Muscle geometry "alfa0": 0.1606, # Initial pennation angle [rad] "F0": 33.75, # Maximum isometric force [N] "L0": 38.9e-3, # Optimal fascicle length [m] "m": 4.67e-3, # Muscle mass [kg] # Passive elements "Kpe": 5, # Passive elastic element stiffness [F0/L0] "b": 0.01, # Muscle fiber viscous element [F0*s/L0] "Em_0": 0.5, # Normalized muscle deformation # Tendon parameters "LT_0": 49e-3, # Tendon length for max isometric force [m] "Kse": 27.8, # Tendon elastic element [F0/LT_0] "cT": 0.0047, # Toe region coefficient "LT_r": 0.964, # Linear region start [LT_0] # Force-Length curve parameters (Type I fibers) # F_L = exp(-|((L^b - 1)/o)|^r) where L is normalized length "b1": 2.3, # Shape parameter for Type I length-force curve "o1": 1.12, # Width parameter for Type I length-force curve "r1": 1.62, # Asymmetry parameter for Type I length-force curve # Force-Length curve parameters (Type II fibers) "b2": 1.55, # Shape parameter for Type II length-force curve "o2": 0.75, # Width parameter for Type II length-force curve "r2": 2.12, # Asymmetry parameter for Type II length-force curve # Force-Velocity curve parameters (Type I fibers) # For concentric: F_V = (bv - V*(av0 + av1*L + av2*L²))/(bv + V) # For eccentric: F_V = (Vmax - V)/(Vmax + V*(cv0 + cv1*L)) "Vmax1": -7.88, # Maximum shortening velocity for Type I [L0/s] "av01": -4.7, # Concentric velocity coefficient a0 for Type I "av11": 8.41, # Concentric velocity coefficient a1 for Type I (length-dependent) "av21": -5.34, # Concentric velocity coefficient a2 for Type I (length²-dependent) "bv1": 0.35, # Concentric force-velocity scaling for Type I "cv01": 5.88, # Eccentric velocity coefficient c0 for Type I "cv11": 0, # Eccentric velocity coefficient c1 for Type I (length-dependent) # Force-Velocity curve parameters (Type II fibers) "Vmax2": -9.15, # Maximum shortening velocity for Type II [L0/s] "av02": -1.53, # Concentric velocity coefficient a0 for Type II "av12": 0, # Concentric velocity coefficient a1 for Type II "av22": 0, # Concentric velocity coefficient a2 for Type II "bv2": 0.69, # Concentric force-velocity scaling for Type II "cv02": 5.7, # Eccentric velocity coefficient c0 for Type II "cv12": 9.18, # Eccentric velocity coefficient c1 for Type II # Muscle-tendon length and moment arm coefficients "Ak": [ 85.199931e-3, -1.184782e-4, -4.6264098e-7, 9.416143e-10, 4.854117e-12, ], "Bk": [6.82847e-3, 4.8396e-5, 3.6942e-8, 6.3113e-10, -6.35837e-11], # Motor unit parameters "RP": 130, # Range of twitch force amplitude "fP": 3, # First peak twitch force [mN] "RT": 3, # Range of contraction time "durType": 1, # Distribution type (1=exponential) "Tl": 90, # Longest twitch duration [ms] "fsatf": 50, # First MU saturation frequency [Hz] "lsatf": 100, # Last MU saturation frequency [Hz] "satType": 1, # Saturation type (1=linear) } elif muscle_type == "Sol": return { # Soleus muscle parameters (larger, stronger muscle) "alfa0": 0.494, "F0": 3586, "L0": 49e-3, "m": 0.526, "Kpe": 5, "b": 0.005, "Em_0": 0.5, "LT_0": 0.289, "Kse": 27.8, "cT": 0.0047, "LT_r": 0.964, "b1": 2.3, "o1": 1.12, "r1": 1.62, "b2": 1.55, "o2": 0.75, "r2": 2.12, "Vmax1": -7.88, "av01": -4.7, "av11": 8.41, "av21": -5.34, "bv1": 0.35, "cv01": 5.88, "cv11": 0, "Vmax2": -9.15, "av02": -1.53, "av12": 0, "av22": 0, "bv2": 0.69, "cv02": 5.7, "cv12": 9.18, "Ak": [0.323, 7.219e-4, -2.243e-6, -3.148e-8, 9.274e-11], "Bk": [-0.041, 2.574e-4, 5.451e-6, -2.219e-8, -5.494e-11], "RP": 130, "fP": 3, "RT": 3, "durType": 1, "Tl": 90, "fsatf": 50, "lsatf": 100, "satType": 1, } else: raise ValueError(f"Unknown muscle type: {muscle_type}. Use 'FDI' or 'Sol'.")