import warnings
from datetime import datetime
try:
import cupy as cp
HAS_CUPY = True
except ImportError:
HAS_CUPY = False
import joblib
import matplotlib.patches as patches
import matplotlib.pyplot as plt
import numpy as np
from beartype import beartype
from scipy.signal import resample
from tqdm import tqdm
from myogen import RANDOM_GENERATOR
from myogen.simulator.core.muscle import Muscle
from myogen.simulator.core.spike_train import MotorNeuronPool
from myogen.simulator.core.emg.simulate_fiber import (
simulate_fiber,
)
from myogen.utils.types import MUAP_SHAPE__TENSOR, SURFACE_EMG__TENSOR
# Suppress warnings for cleaner output during batch simulations
warnings.filterwarnings("ignore")
[docs]
@beartype
class SurfaceEMG:
"""
Surface Electromyography (sEMG) Simulation.
This class provides a biophysical simulation framework for generating
surface electromyography signals from the muscle. It implements a
multi-layered cylindrical volume conductor model [1]_ that accounts for
anatomical accuracy, physiological variability, and measurement noise
characteristics typical of clinical and research EMG recordings.
The simulation is built on established biophysical principles and validated
anatomical data, making it suitable for algorithm development, method validation,
and educational purposes in EMG signal processing and motor control research.
Parameters
----------
muscle_model : Muscle
Pre-computed muscle model containing motor unit territories, fiber distributions,
and recruitment patterns. Must be created using myogen.simulator.Muscle class.
The muscle model defines the spatial organization of motor units and their
associated muscle fibers within the FDI muscle volume.
sampling_frequency__Hz : float, default=2048.0
Temporal sampling frequency for MUAP and EMG signals in Hertz.
Higher frequencies provide better temporal resolution but increase computational
cost and memory usage.
Typical ranges:
- Clinical EMG: 1024-2048 Hz.
- Research applications: 2048-4096 Hz.
- High-resolution studies: >4096 Hz.
mean_conduction_velocity__mm_s : float, default=4.1
Mean conduction velocity of action potentials along muscle fibers in mm/s.
Based on experimental measurements from Botelho et al. (2019).
This parameter significantly affects MUAP duration and propagation patterns.
Typical range for FDI muscle: 3.5-4.5 mm/s.
sampling_points_in_t_and_z_domains : int, default=256
Spatial and temporal discretization resolution for numerical integration.
Controls the accuracy of the volume conductor calculations but significantly
impacts computational cost (scales quadratically). Higher values provide
better numerical accuracy at the expense of simulation time.
- Fast simulations: 128-256 points.
- Standard accuracy: 256-512 points.
- High accuracy: 512-1024 points.
sampling_points_in_theta_domain : int, default=180
Angular discretization for cylindrical coordinate system in degrees.
180 points provides 2° angular resolution, which is sufficient for most
surface EMG applications. Higher values provide better spatial resolution
for circumferential electrode placement studies.
MUs_to_simulate : list[int] or None, default=None
Indices of specific motor units to simulate. If None, simulates all motor
units present in the muscle model. For computational efficiency, consider
simulating subsets for initial analysis or algorithm development.
Motor unit indices correspond to recruitment order (0 = first recruited).
mean_fiber_length__mm : float, default=32
Mean muscle fiber length in millimeters based on anatomical measurements
(Jacobson et al., 1992). This parameter affects MUAP duration and amplitude
characteristics. Longer fibers produce longer-duration MUAPs with potentially
higher amplitudes. Typical range for FDI: 28-36 mm.
var_fiber_length__mm : float, default=3
Standard deviation of fiber length distribution in millimeters.
Introduces realistic biological variability in MUAP shapes within motor units.
Higher values increase MUAP shape variability but maintain physiological realism.
Typical range: 2-5 mm.
radius_bone__mm : float, default=0
Inner bone radius for cylindrical volume conductor model in millimeters.
For surface EMG applications, bone effects are typically minimal, so default
value of 0 is appropriate. Non-zero values may be used for intramuscular EMG
or detailed anatomical studies.
fat_thickness__mm : float, default=1
Subcutaneous fat layer thickness in millimeters. This parameter significantly
affects MUAP amplitude and spatial distribution due to fat's low electrical
conductivity acting as an insulator. Based on ultrasound measurements
(Störchle et al., 2018). Typical range: 0.5-3 mm for hand muscles.
skin_thickness__mm : float, default=1
Skin layer thickness in millimeters. Generally less critical than fat thickness
but affects high-frequency components of MUAPs and electrode-tissue coupling.
Based on histological measurements (Brodar, 1960). Typical range: 0.5-2 mm.
muscle_conductivity_radial__S_m : float, default=0.09
Muscle tissue electrical conductivity in radial direction (perpendicular to fibers)
in Siemens per meter. Muscle tissue exhibits anisotropic conductivity due to
fiber orientation, with lower conductivity perpendicular to fibers.
Value based on Botelho et al. (2019).
muscle_conductivity_longitudinal__S_m : float, default=0.4
Muscle tissue electrical conductivity in longitudinal direction (parallel to fibers)
in Siemens per meter. Approximately 4-5x higher than radial conductivity due to
fiber structure and orientation. This anisotropy significantly affects MUAP
spatial patterns and propagation characteristics.
fat_conductivity__S_m : float, default=0.041
Subcutaneous fat tissue electrical conductivity in Siemens per meter.
Low conductivity makes fat act as an electrical insulator, spatially smoothing
MUAP patterns and reducing amplitudes. Based on experimental measurements
from tissue impedance studies.
skin_conductivity__S_m : float, default=1
Skin tissue electrical conductivity in Siemens per meter.
Relatively high conductivity provides good electrical coupling between
underlying tissues and surface electrodes. Value based on Roeleveld et al. (1997).
electrode_grid_inclination_angle__deg : float, default=0
Rotation angle of electrode grid relative to muscle fiber direction in degrees.
- 0° = electrodes aligned parallel to muscle fibers
- 90° = electrodes aligned perpendicular to muscle fibers
This parameter affects MUAP propagation patterns and is important for
studies of fiber direction estimation and conduction velocity analysis.
electrode_grid_dimensions__rows_cols : ElectrodeGridDimensions, default=(8, 8)
Number of electrode rows and columns in the recording grid.
Total number of electrodes = rows × columns.
Higher densities provide better spatial resolution but increase data volume.
inter_electrode_distance__mm : float, default=4
Distance between adjacent electrodes in millimeters.
Affects spatial resolution and signal crosstalk between channels.
Smaller distances provide higher spatial resolution but may increase
correlated noise between adjacent channels.
electrode_radius__mm : float, default=0.75
Physical radius of individual circular electrodes in millimeters.
Affects spatial averaging (larger electrodes average over more tissue volume)
and signal-to-noise ratio (larger electrodes typically have lower impedance).
Standard clinical surface electrodes: 0.5-2 mm radius.
electrode_grid_center_positions : list[ElectrodeGridCenterPosition] or None, default=None
List of electrode grid center positions in cylindrical coordinates.
Each position is specified as (z_position_mm, angle_radians) where:
- z_position_mm: position along muscle fiber direction (+ = distal, - = proximal)
- angle_radians: circumferential position around muscle (0 = anterior)
If None, uses single position at anatomical muscle belly center computed
from the center of mass of all motor unit territories.
Multiple positions enable spatial analysis of EMG characteristics across
different muscle regions.
Example positions:
- [(0, 0)]: Single center position
- [(0, 0), (-10, 0), (10, 0)]: Proximal-center-distal sequence
- [(0, 0), (0, π/2), (0, π)]: Circumferential positions
Attributes
----------
resulting_muaps_array : MUAP_SHAPE__TENSOR
Generated MUAP templates after calling simulate_muaps().
Shape: (n_positions, n_motor_units, n_electrode_rows, n_electrode_cols, n_time_samples)
surface_emg__tensor : SURFACE_EMG__TENSOR
Generated surface EMG signals after calling simulate_surface_emg().
Shape: (n_positions, n_pools, n_electrode_rows, n_electrode_cols, n_time_samples)
noisy_surface_emg : SURFACE_EMG__TENSOR
Surface EMG with added noise after calling add_noise().
Same shape as surface_emg__tensor.
Raises
------
TypeError
If any input parameter has incorrect type (enforced by beartype decorator).
ValueError
If muscle_model is empty or if electrode parameters are physically unrealistic
(e.g., negative dimensions, impossible conductivity values).
Examples
--------
**Standard Configuration:**
>>> muscle = myogen.simulator.Muscle(...)
>>> surface_emg = SurfaceEMG(
... muscle_model=muscle,
... sampling_frequency__Hz=2048,
... electrode_grid_dimensions__rows_cols=(8, 8),
... MUs_to_simulate=list(range(10)) # First 10 motor units
... )
**High-Resolution Configuration:**
>>> surface_emg = SurfaceEMG(
... muscle_model=muscle,
... sampling_frequency__Hz=4096, # Higher temporal resolution
... sampling_points_in_t_and_z_domains=512, # Higher spatial resolution
... electrode_grid_dimensions__rows_cols=(16, 8), # More electrodes
... inter_electrode_distance__mm=2, # Higher spatial sampling
... )
**Multi-Position Spatial Analysis:**
>>> positions = [
... (0, 0), # Center
... (-8, np.deg2rad(0)), # 8mm proximal
... (8, np.deg2rad(0)), # 8mm distal
... (0, np.deg2rad(90)), # 90° circumferential
... ]
>>> surface_emg = SurfaceEMG(
... muscle_model=muscle,
... electrode_grid_center_positions=positions
... )
**Thin Subcutaneous Layer Configuration:**
>>> surface_emg = SurfaceEMG(
... muscle_model=muscle,
... fat_thickness__mm=0.5, # Thin fat layer
... skin_thickness__mm=0.8, # Thin skin layer
... ) # Results in higher amplitude, more spatially localized MUAPs
Notes
-----
The simulation framework is based on:
- **Volume Conductor Theory**: Multi-layered cylindrical model representing muscle, fat,
and skin tissues with anisotropic conductivity properties.
- **Motor Unit Physiology**: Realistic motor unit territories, fiber distributions, and
action potential propagation based on anatomical and physiological measurements.
- **Electrode Array Modeling**: High-density surface electrode grids with configurable
spatial arrangements and electrode properties.
- **Signal Processing Pipeline**: Complete workflow from single-fiber action potentials
to composite surface EMG signals with noise characteristics.
Key capabilities:
- **Multi-Position Electrode Arrays**: Simulate EMG at multiple spatial locations
- **Motor Unit Action Potentials (MUAPs)**: Generate individual MUAP templates
- **Composite Surface EMG**: Convolve MUAPs with spike trains for realistic EMG
- **Noise Modeling**: Add measurement noise with specified signal-to-noise ratios
- **Computational Optimization**: Efficient matrix reuse for large-scale simulations
- **Visualization Support**: Built-in plotting capabilities for model validation
Typical workflow:
1. Create muscle model using `myogen.simulator.Muscle`
2. Initialize `SurfaceEMG` with desired parameters
3. Generate MUAP templates using `simulate_muaps()`
4. Create motor neuron pool using `myogen.simulator.MotorNeuronPool`
5. Generate surface EMG using `simulate_surface_emg()`
6. Optionally add noise using `add_noise()`
Performance considerations:
- Memory usage scales as: n_positions × n_motor_units × grid_area × time_samples
- Computational cost increases with: fiber count, electrode density, temporal resolution
- For large simulations (>50 MUs, >64 electrodes), consider parameter optimization
- GPU acceleration used automatically for convolution operations in surface EMG generation
Validation and quality assurance:
- Parameters based on peer-reviewed anatomical and physiological studies
- Built-in validation checks for parameter consistency
- Extensive documentation with literature references
- Type checking enforced via beartype decorators
See Also
--------
myogen.simulator.Muscle : For creating anatomically accurate muscle models
myogen.simulator.MotorNeuronPool : For generating motor neuron spike trains
myogen.utils.plotting.plot_surface_emg : For visualizing simulation results
References
----------
.. [1] Farina, D., Mesin, L., Martina, S., Merletti, R., 2004.
A surface EMG generation model with multilayer cylindrical description of the volume conductor.
IEEE Transactions on Biomedical Engineering 51, 415–426. https://doi.org/10.1109/TBME.2003.820998
"""
def __init__(
self,
muscle_model: Muscle, # Resolution parameters
sampling_frequency__Hz: float = 2048.0,
mean_conduction_velocity__mm_s: float = 4.1, # Mean conduction velocity (mm/s) -> (Botelho, 2019)
sampling_points_in_t_and_z_domains: int = 256, # Number of points in t and z domains (arbitrary)
sampling_points_in_theta_domain: int = 180, # Number of points in theta domain (arbitrary)
# Muscle parameters (FDI)
MUs_to_simulate: list[int]
| None = None, # List of motor unit indices to simulate
mean_fiber_length__mm: float = 32, # Mean fiber length -> (Jacobson, 1992)
var_fiber_length__mm: float = 3, # Fiber length variation (+ or -) -> (Jacobson, 1992)
radius_bone__mm: float = 0,
fat_thickness__mm: float = 1, # (Storchle, 2018)
skin_thickness__mm: float = 1, # (Brodar, 1960)
# Conductivities -> (Botelho, 2019)
muscle_conductivity_radial__S_m: float = 0.09,
muscle_conductivity_longitudinal__S_m: float = 0.4,
fat_conductivity__S_m: float = 0.041,
skin_conductivity__S_m: float = 1, # (Roeleveld, 1997)
# Electrode Grid parameters
electrode_grid_inclination_angle__deg: float = 0,
electrode_grid_dimensions__rows_cols: tuple[int, int] = (8, 8),
inter_electrode_distance__mm: float = 4,
electrode_radius__mm: float = 0.75,
electrode_grid_center_positions: list[tuple[float | int, float | int] | None]
| None = None,
):
self.muscle_model = muscle_model
self.sampling_frequency__Hz = sampling_frequency__Hz
self.mean_conduction_velocity__mm_s = mean_conduction_velocity__mm_s
self.sampling_points_in_t_and_z_domains = sampling_points_in_t_and_z_domains
self.sampling_points_in_theta_domain = sampling_points_in_theta_domain
self.MUs_to_simulate = MUs_to_simulate
self.mean_fiber_length__mm = mean_fiber_length__mm
self.var_fiber_length__mm = var_fiber_length__mm
self.radius_bone__mm = radius_bone__mm
self.fat_thickness__mm = fat_thickness__mm
self.skin_thickness__mm = skin_thickness__mm
self.muscle_conductivity_radial__S_m = muscle_conductivity_radial__S_m
self.muscle_conductivity_longitudinal__S_m = (
muscle_conductivity_longitudinal__S_m
)
self.fat_conductivity__S_m = fat_conductivity__S_m
self.skin_conductivity__S_m = skin_conductivity__S_m
self.electrode_grid_inclination_angle__deg = (
electrode_grid_inclination_angle__deg
)
self.electrode_grid_dimensions__rows_cols = electrode_grid_dimensions__rows_cols
self.inter_electrode_distance__mm = inter_electrode_distance__mm
self.electrode_radius__mm = electrode_radius__mm
self.electrode_grid_center_positions = electrode_grid_center_positions
# Convert sampling frequency from Hz to kHz for internal calculations
# (Historical convention in the simulation framework)
self._sampling_frequency__kHz = self.sampling_frequency__Hz / 1000.0
[docs]
def simulate_muaps(
self, show_plots: bool = False, verbose: bool = True
) -> MUAP_SHAPE__TENSOR:
"""
Simulate MUAPs for the muscle model.
Parameters
----------
show_plots : bool, optional
Whether to show plots of the MUAPs. Default is False.
verbose : bool, optional
Whether to print verbose output. Default is True.
Returns
-------
resulting_muaps_array : MUAP_SHAPE__TENSOR
Generated MUAP templates after calling simulate_muaps().
Shape: (n_positions, n_motor_units, n_electrode_rows, n_electrode_cols, n_time_samples)
"""
if verbose:
start = datetime.now()
print("Starting FDI muscle EMG simulation...")
print(
f"Muscle model: {len(self.muscle_model.resulting_number_of_innervated_fibers)} motor units"
)
# Set default MUs_to_simulate if not provided - simulate all available motor units
if self.MUs_to_simulate is None:
self.MUs_to_simulate = list(
range(len(self.muscle_model.resulting_number_of_innervated_fibers))
)
if verbose:
print(
f"No specific MUs specified - simulating all {len(self.MUs_to_simulate)} motor units"
)
# Set default electrode centers if not provided - single position at muscle center
if self.electrode_grid_center_positions is None:
self.electrode_grid_center_positions = [None] # Single default position
if verbose:
print("Using default electrode position at anatomical muscle center")
# Calculate derived geometric parameters for the volume conductor model
# Innervation zone variance based on fiber length distribution (Botelho, 2019)
innervation_zone_variance = self.mean_fiber_length__mm / 10
# Total radius includes all tissue layers for cylindrical volume conductor
self.radius_total = (
self.muscle_model.radius__mm
+ self.fat_thickness__mm
+ self.skin_thickness__mm
)
# Extract motor unit fiber counts from the pre-computed muscle model
number_of_fibers_per_MUs = (
self.muscle_model.resulting_number_of_innervated_fibers
)
# Create temporal sampling array for MUAP time series
# Time array spans from 0 to (N-1)/Fs to avoid endpoint duplication in FFT
t = np.linspace(
0,
(self.sampling_points_in_t_and_z_domains - 1)
/ self._sampling_frequency__kHz,
self.sampling_points_in_t_and_z_domains,
)
# Generate color map for motor unit visualization (if plots are enabled)
colors = plt.cm.get_cmap("tab20")(np.linspace(0, 1, len(self.MUs_to_simulate)))
# Pre-calculate innervation zone positions for all motor units
# Innervation zones are randomly distributed within physiological bounds
# This introduces realistic variability in MUAP shapes
innervation_zones = RANDOM_GENERATOR.uniform(
low=-innervation_zone_variance / 2,
high=innervation_zone_variance / 2,
size=len(self.MUs_to_simulate),
)
# Calculate default electrode center position based on muscle anatomy
# Uses center of mass of all fiber positions as anatomically-informed default
default_center = None
if None in self.electrode_grid_center_positions:
all_fiber_positions = []
all_innervation_zones = []
# Collect all fiber positions to compute anatomical center
for MU_number, MU_index in enumerate(self.MUs_to_simulate):
number_of_fibers = number_of_fibers_per_MUs[MU_index]
if number_of_fibers == 0:
continue
self.position_of_fibers = self.muscle_model.resulting_fiber_assignment(
MU_index
)
innervation_zone = innervation_zones[MU_number]
for fiber_number in range(number_of_fibers):
# Convert Cartesian coordinates to angular position for center calculation
all_fiber_positions.append(
np.arctan2(*self.position_of_fibers[fiber_number][::-1]) # type: ignore
)
all_innervation_zones.append(innervation_zone)
if len(all_fiber_positions) > 0:
# Anatomical center = center of mass of innervation zones and fiber positions
default_center = (
np.mean(
all_innervation_zones
), # Z-position (along fiber direction)
np.rad2deg(
np.mean(all_fiber_positions)
), # Angular position (degrees)
)
else:
# Fallback to geometric center if no fibers found
default_center = (0, 0)
if verbose:
print(
f"Computed anatomical center: z={default_center[0]:.2f}mm, θ={default_center[1]:.1f}°"
)
# Prepare electrode centers list with unit conversion (radians to degrees)
self.electrode_grid_centers = [
default_center
if electrode_grid_center is None
else (electrode_grid_center[0], np.rad2deg(electrode_grid_center[1]))
for electrode_grid_center in self.electrode_grid_center_positions
]
if verbose:
print(
f"Electrode positions: {len(self.electrode_grid_centers)} position(s)"
)
print(
f"Electrode grid: {self.electrode_grid_dimensions__rows_cols[0]}×{self.electrode_grid_dimensions__rows_cols[1]} = {np.prod(self.electrode_grid_dimensions__rows_cols)} channels"
)
# Initialize output array for all results
# Shape: (n_positions, N_MU, electrode_rows, electrode_cols, time_samples)
self.resulting_muaps_array = np.zeros(
(
len(self.electrode_grid_centers),
len(self.MUs_to_simulate),
*self.electrode_grid_dimensions__rows_cols,
len(t),
)
)
if verbose:
array_size_mb = self.resulting_muaps_array.nbytes / (1024**2)
print(
f"Output array size: {self.resulting_muaps_array.shape} ({array_size_mb:.1f} MB)"
)
# Main simulation loop - process each motor unit independently
for MU_number, MU_index in enumerate(self.MUs_to_simulate):
number_of_fibers = number_of_fibers_per_MUs[MU_index]
# Skip motor units with no assigned fibers
if number_of_fibers == 0:
if verbose:
print(
f"Warning: No fibers assigned to MU {MU_number + 1}. Skipping."
)
continue
# Get spatial positions of all fibers in this motor unit
self.position_of_fibers = self.muscle_model.resulting_fiber_assignment(
MU_index
)
innervation_zone = innervation_zones[MU_number]
if verbose:
print(
f"Processing MU {MU_number + 1}/{len(self.MUs_to_simulate)}: {number_of_fibers} fibers"
)
# Optional visualization of motor unit territory
if show_plots:
_, ax = plt.subplots(figsize=(8, 8))
# Draw muscle boundary
ax.add_patch(
patches.Circle(
(0, 0), self.muscle_model.radius__mm, color="r", alpha=0.1
)
)
ax.set_title(f"Motor Unit {MU_number + 1} Territory")
ax.set_xlabel("X position (mm)")
ax.set_ylabel("Y position (mm)")
# Matrix optimization variables for computational efficiency
# These matrices contain position-independent calculations that can be reused
A_matrix = None
B_incomplete = None
# Process each fiber within the motor unit
for pos_idx, electrode_grid_center in enumerate(
self.electrode_grid_centers
):
for fiber_number in tqdm(
range(number_of_fibers),
desc=f"MU {MU_number + 1} Position {pos_idx + 1}",
):
# Get fiber position in Cartesian coordinates (mm)
self.fiber_position = self.position_of_fibers[fiber_number]
# Add fiber to visualization plot
if show_plots:
ax.scatter(
*self.fiber_position, color=colors[MU_number], alpha=1, s=10
) # type: ignore
# Calculate fiber distance from muscle center (radial position)
R = np.sqrt(
self.fiber_position[0] ** 2 + self.fiber_position[1] ** 2
)
# Generate realistic fiber length with biological variability
fiber_length__mm = (
self.mean_fiber_length__mm
+ RANDOM_GENERATOR.uniform(
low=-self.var_fiber_length__mm,
high=self.var_fiber_length__mm,
)
)
# Calculate fiber end positions relative to innervation zone
# L1, L2 = distances from innervation zone to fiber ends
L2 = abs(innervation_zone - fiber_length__mm / 2) # Proximal end
L1 = abs(innervation_zone + fiber_length__mm / 2) # Distal end
# OPTIMIZATION: For the first fiber, compute expensive matrices once
# For subsequent fibers, reuse these matrices across all electrode positions
if fiber_number == 0 or A_matrix is None:
# Compute matrices for the first electrode position
phi_temp, A_matrix, B_incomplete = simulate_fiber(
Fs=self._sampling_frequency__kHz,
v=self.mean_conduction_velocity__mm_s,
N=self.sampling_points_in_t_and_z_domains,
M=self.sampling_points_in_theta_domain,
r=self.radius_total,
r_bone=self.radius_bone__mm,
th_fat=self.fat_thickness__mm,
th_skin=self.skin_thickness__mm,
R=R,
L1=L1,
L2=L2,
zi=innervation_zone,
alpha=self.electrode_grid_inclination_angle__deg,
channels=self.electrode_grid_dimensions__rows_cols,
center=electrode_grid_center,
d_ele=self.inter_electrode_distance__mm,
rele=self.electrode_radius__mm,
sig_muscle_rho=self.muscle_conductivity_radial__S_m,
sig_muscle_z=self.muscle_conductivity_longitudinal__S_m,
sig_skin=self.skin_conductivity__S_m,
sig_fat=self.fat_conductivity__S_m,
)
# Add first position result to output array
self.resulting_muaps_array[pos_idx, MU_number] += phi_temp
else:
# For subsequent fibers, reuse matrices for all electrode positions
# This provides significant computational speedup for multi-position simulations
phi_temp, _, _ = simulate_fiber(
Fs=self._sampling_frequency__kHz,
v=self.mean_conduction_velocity__mm_s,
N=self.sampling_points_in_t_and_z_domains,
M=self.sampling_points_in_theta_domain,
r=self.radius_total,
r_bone=self.radius_bone__mm,
th_fat=self.fat_thickness__mm,
th_skin=self.skin_thickness__mm,
R=R,
L1=L1,
L2=L2,
zi=innervation_zone,
alpha=self.electrode_grid_inclination_angle__deg,
channels=self.electrode_grid_dimensions__rows_cols,
center=electrode_grid_center,
d_ele=self.inter_electrode_distance__mm,
rele=self.electrode_radius__mm,
sig_muscle_rho=self.muscle_conductivity_radial__S_m,
sig_muscle_z=self.muscle_conductivity_longitudinal__S_m,
sig_skin=self.skin_conductivity__S_m,
sig_fat=self.fat_conductivity__S_m,
A_matrix=A_matrix, # Reuse cached matrix
B_incomplete=B_incomplete, # Reuse cached matrix
)
self.resulting_muaps_array[pos_idx, MU_number] += phi_temp
# Display motor unit territory visualization
if show_plots:
ax.set_aspect("equal", adjustable="box")
ax.grid(True, alpha=0.3)
plt.show()
# Print simulation summary and performance metrics
if verbose:
simulation_time = datetime.now() - start
print(f"\n{'=' * 60}")
print(f"Simulation completed successfully!")
print(f"Total simulation time: {simulation_time}")
print(
f"Generated MUAPs for {len(self.electrode_grid_center_positions)} electrode position(s)"
)
print(f"Simulated {len(self.MUs_to_simulate)} motor units")
print(f"Output array shape: {self.resulting_muaps_array.shape}")
print(
f"Output array size: {self.resulting_muaps_array.nbytes / (1024**2):.1f} MB"
)
print(
f"Time per motor unit: {simulation_time.total_seconds() / len(self.MUs_to_simulate):.2f} seconds"
)
print(f"{'=' * 60}")
return self.resulting_muaps_array
[docs]
def simulate_surface_emg(
self,
motor_neuron_pool: MotorNeuronPool,
) -> SURFACE_EMG__TENSOR:
"""
Generate realistic surface EMG signals by convolving MUAP templates with motor neuron spike trains.
This method implements the final step of surface EMG simulation by combining the previously
computed Motor Unit Action Potential (MUAP) templates with physiologically realistic motor
neuron firing patterns. The process involves temporal resampling to match simulation timesteps,
convolution of spike trains with MUAP shapes, and summation across all active motor units
for each electrode channel.
The simulation can optionally use GPU acceleration (via CuPy) for efficient parallel processing of
convolution operations across multiple electrode positions, motor units, and time points.
If CuPy is not available, the simulation automatically falls back to CPU computation with NumPy.
Parameters
----------
motor_neuron_pool : MotorNeuronPool
Motor neuron pool containing spike trains and simulation parameters.
Must contain:
- spike_trains: Binary spike train matrix [n_pools, n_motor_units, n_time_samples]
- timestep__ms: Simulation timestep in milliseconds
- active_neuron_indices: List of active motor unit indices for each pool
The motor neuron pool defines the temporal firing patterns that will be
convolved with the MUAP templates to generate surface EMG signals.
Returns
-------
surface_emg__tensor : SURFACE_EMG__TENSOR
Surface EMG tensor with shape (n_positions, n_pools, n_electrodes, n_time_samples)
Raises
------
AttributeError
If simulate_muaps() has not been called first to generate MUAP templates.
ValueError
If motor_neuron_pool contains no active motor units or if there's a mismatch
between simulated motor units and available spike trains.
RuntimeError
If GPU memory is insufficient for large simulations when using CuPy
(will automatically fall back to CPU-based computation).
Notes
-----
**Computational Complexity:**
- Time complexity: O(n_positions × n_pools × n_MUs × n_electrodes × n_time_samples)
- Space complexity: O(n_positions × n_pools × n_electrodes × n_time_samples)
- GPU acceleration (if available) provides 10-50x speedup for large simulations
**Memory Requirements:**
- Input MUAPs: n_positions × n_MUs × n_electrodes × n_time_samples × 8 bytes
- Spike trains: n_pools × n_MUs × n_time_samples × 8 bytes
- Output EMG: n_positions × n_pools × n_electrodes × n_time_samples × 8 bytes
- Example: 4 positions × 10 pools × 100 MUs × 64 electrodes × 10000 samples ≈ 2 GB
**Signal Characteristics:**
- Amplitude range: typically 10-1000 μV for surface EMG
- Frequency content: 10-500 Hz (depending on muscle and electrode configuration)
- Temporal patterns reflect motor unit recruitment and firing rate modulation
- Spatial patterns depend on motor unit territories and electrode positioning
**Validation Recommendations:**
- Verify EMG amplitude scaling with muscle activation level
- Check frequency content matches physiological expectations
- Ensure spatial patterns are consistent with motor unit territories
- Compare multi-channel correlations with experimental data
Examples
--------
**Basic Surface EMG Simulation:**
>>> # First generate MUAP templates
>>> muaps = surface_emg.simulate_muaps()
>>>
>>> # Create motor neuron pool with spike trains
>>> motor_pool = myogen.simulator.MotorNeuronPool(
... spike_trains=spike_data,
... timestep__ms=1.0,
... active_neuron_indices=[[0, 1, 2, 3, 4]] # First 5 MUs active
... )
>>>
>>> # Generate surface EMG
>>> emg_signals = surface_emg.simulate_surface_emg(motor_pool)
>>> print(f"EMG shape: {emg_signals.shape}") # (1, 1, 8, 8, 10000)
**Multi-Condition Simulation:**
>>> # Motor pool with multiple activation conditions
>>> motor_pool = myogen.simulator.MotorNeuronPool(
... spike_trains=multi_condition_spikes, # [n_conditions, n_MUs, n_samples]
... timestep__ms=0.5,
... active_neuron_indices=[
... [0, 1, 2], # Low activation: 3 MUs
... [0, 1, 2, 3, 4, 5], # Medium activation: 6 MUs
... list(range(10)) # High activation: 10 MUs
... ]
... )
>>>
>>> emg_multi = surface_emg.simulate_surface_emg(motor_pool)
>>> print(f"Multi-condition EMG: {emg_multi.shape}") # (n_pos, 3, 8, 8, n_samples)
**Analysis of Specific Electrodes:**
>>> emg = surface_emg.simulate_surface_emg(motor_pool)
>>>
>>> # Extract signal from center electrode
>>> center_row, center_col = 4, 4 # Center of 8x8 grid
>>> center_emg = emg[0, 0, center_row, center_col, :]
>>>
>>> # Compute RMS amplitude across grid
>>> rms_amplitudes = np.sqrt(np.mean(emg[0, 0, :, :, :] ** 2, axis=2))
>>> print(f"RMS amplitude map shape: {rms_amplitudes.shape}") # (8, 8)
**Multi-Position Comparison:**
>>> # Compare EMG characteristics across electrode positions
>>> emg = surface_emg.simulate_surface_emg(motor_pool)
>>>
>>> for pos_idx in range(emg.shape[0]):
... pos_emg = emg[pos_idx, 0, :, :, :]
... max_amplitude = np.max(np.abs(pos_emg))
... print(f"Position {pos_idx}: Max amplitude = {max_amplitude:.2f} μV")
See Also
--------
simulate_muaps : Generate MUAP templates (must be called first)
add_noise : Add measurement noise to simulated EMG signals
myogen.simulator.MotorNeuronPool : Create motor neuron spike trains
myogen.utils.plotting.plot_surface_emg : Visualize simulated EMG signals
"""
# Validate that MUAPs have been computed
if (
not hasattr(self, "resulting_muaps_array")
or self.resulting_muaps_array is None
):
raise AttributeError(
"MUAP templates have not been generated. Call simulate_muaps() first."
)
# Temporal resampling to match motor neuron pool timestep
# Note: resample function from scipy.signal
muap_shapes__tensor = np.asarray(
resample(
self.resulting_muaps_array,
int(
(self.resulting_muaps_array.shape[-1] / self.sampling_frequency__Hz)
// (motor_neuron_pool.timestep__ms / 1000)
),
axis=-1,
)
)
# Handle None case for MUs_to_simulate and convert to set for efficient lookup
if self.MUs_to_simulate is None:
MUs_to_simulate = set(
range(len(self.muscle_model.resulting_number_of_innervated_fibers))
)
else:
MUs_to_simulate = set(self.MUs_to_simulate)
n_pools = motor_neuron_pool.spike_trains.shape[0]
n_positions = muap_shapes__tensor.shape[0]
n_rows = muap_shapes__tensor.shape[2]
n_cols = muap_shapes__tensor.shape[3]
# Initialize the result array with the correct shape
# The shape will depend on the length of the convolution result
sample_conv = np.convolve(
motor_neuron_pool.spike_trains[0, 0],
muap_shapes__tensor[0, 0, 0, 0],
mode="same",
)
# Perform the convolution and summation using GPU acceleration if available
if HAS_CUPY:
# Use GPU acceleration with CuPy
spike_gpu = cp.asarray(motor_neuron_pool.spike_trains)
muap_gpu = cp.asarray(muap_shapes__tensor)
surface_emg_gpu = cp.zeros(
(n_positions, n_pools, n_rows, n_cols, len(sample_conv))
)
for mu_pool_idx in tqdm(
range(n_pools), desc="Simulating surface EMG (GPU)"
):
active_neuron_indices = set(
motor_neuron_pool.active_neuron_indices[mu_pool_idx]
)
for position_idx in range(n_positions):
for row_idx in range(n_rows):
for col_idx in range(n_cols):
# Process all MUAPs for this combination on GPU
convolutions = cp.array(
[
cp.correlate(
spike_gpu[mu_pool_idx, muap_idx],
muap_gpu[
position_idx, muap_idx, row_idx, col_idx
],
mode="same",
)
for muap_idx in MUs_to_simulate.intersection(
active_neuron_indices
)
]
)
# Sum across MUAPs on GPU
surface_emg_gpu[
position_idx, mu_pool_idx, row_idx, col_idx
] = cp.sum(convolutions, axis=0)
# Transfer results back to CPU
self.surface_emg__tensor = cp.asnumpy(surface_emg_gpu)
else:
# Fallback to CPU computation with NumPy
surface_emg_cpu = np.zeros(
(n_positions, n_pools, n_rows, n_cols, len(sample_conv))
)
for mu_pool_idx in tqdm(
range(n_pools), desc="Simulating surface EMG (CPU)"
):
active_neuron_indices = set(
motor_neuron_pool.active_neuron_indices[mu_pool_idx]
)
for position_idx in range(n_positions):
for row_idx in range(n_rows):
for col_idx in range(n_cols):
# Process all MUAPs for this combination on CPU
convolutions = np.array(
[
np.correlate(
motor_neuron_pool.spike_trains[
mu_pool_idx, muap_idx
],
muap_shapes__tensor[
position_idx, muap_idx, row_idx, col_idx
],
mode="same",
)
for muap_idx in MUs_to_simulate.intersection(
active_neuron_indices
)
]
)
# Sum across MUAPs on CPU
surface_emg_cpu[
position_idx, mu_pool_idx, row_idx, col_idx
] = np.sum(convolutions, axis=0)
self.surface_emg__tensor = surface_emg_cpu
return self.surface_emg__tensor
[docs]
def add_noise(
self, snr_db: float, noise_type: str = "gaussian"
) -> SURFACE_EMG__TENSOR:
"""
Add noise to the surface EMG signal to achieve a specified Signal-to-Noise Ratio (SNR).
This function calculates the appropriate noise level based on the signal power and
desired SNR, then adds noise to the simulated surface EMG signals. This is useful
for studying the effects of measurement noise on EMG analysis algorithms.
Parameters
----------
snr_db : float
Target Signal-to-Noise Ratio in decibels (dB). Higher values mean less noise.
Typical values for surface EMG:
- High quality: 20-30 dB
- Medium quality: 10-20 dB
- Low quality: 0-10 dB
- Very noisy: < 0 dB
noise_type : str, default="gaussian"
Type of noise to add.
Currently supports:
- "gaussian": Additive white Gaussian noise (most common for EMG).
Returns
-------
noisy_surface_emg : SURFACE_EMG__TENSOR
Surface EMG tensor with added noise, same shape as self.surface_emg__tensor.
The original signal is preserved in self.surface_emg__tensor.
Raises
------
ValueError
If surface EMG has not been simulated yet (call simulate_surface_emg first)
If noise_type is not supported
Notes
-----
**SNR Calculation**:
SNR_dB = 10 * log10(P_signal / P_noise)
Where:
- P_signal = mean power of the signal across all channels
- P_noise = power of the additive noise
The noise power is calculated as:
P_noise = P_signal / (10^(SNR_dB/10))
**Noise Characteristics**:
- Gaussian noise: Zero-mean with variance calculated to achieve target SNR
- Noise is added independently to each electrode channel
- Noise power is calculated based on the RMS power of the signal
Examples
--------
>>> # Simulate surface EMG first
>>> surface_emg = emg_simulator.simulate_surface_emg(motor_neuron_pool)
>>>
>>> # Add moderate noise (15 dB SNR)
>>> noisy_emg = emg_simulator.add_noise(snr_db=15)
>>>
>>> # Add high noise (5 dB SNR)
>>> very_noisy_emg = emg_simulator.add_noise(snr_db=5)
>>>
>>> print(f"Original EMG shape: {surface_emg.shape}")
>>> print(f"Noisy EMG shape: {noisy_emg.shape}")
"""
# Check if surface EMG has been simulated
if not hasattr(self, "surface_emg__tensor") or self.surface_emg__tensor is None:
raise ValueError(
"Surface EMG has not been simulated yet. "
"Call simulate_surface_emg() first before adding noise."
)
# Validate noise type
if noise_type.lower() not in ["gaussian"]:
raise ValueError(
f"Unsupported noise type: {noise_type}. Currently supported: 'gaussian'"
)
# Calculate signal power (RMS power across all dimensions)
signal_power = np.mean(self.surface_emg__tensor**2)
# Calculate target noise power from SNR
# SNR_dB = 10 * log10(P_signal / P_noise)
# Therefore: P_noise = P_signal / (10^(SNR_dB/10))
snr_linear = 10 ** (snr_db / 10)
noise_power = signal_power / snr_linear
# Generate noise with appropriate standard deviation
if noise_type.lower() == "gaussian":
# For Gaussian noise, variance = power, std = sqrt(power)
noise_std = np.sqrt(noise_power)
# Generate noise with same shape as signal
noise = RANDOM_GENERATOR.normal(
loc=0.0, # Zero mean
scale=noise_std, # Standard deviation to achieve target SNR
size=self.surface_emg__tensor.shape,
)
# Add noise to signal
self.noisy_surface_emg = self.surface_emg__tensor + noise
# Verify achieved SNR (for validation)
achieved_signal_power = np.mean(self.surface_emg__tensor**2)
achieved_noise_power = np.mean(noise**2)
achieved_snr_db = 10 * np.log10(achieved_signal_power / achieved_noise_power)
print(f"Target SNR: {snr_db:.2f} dB")
print(f"Achieved SNR: {achieved_snr_db:.2f} dB")
return self.noisy_surface_emg
# For backward compatibility - run simulation if script is executed directly
if __name__ == "__main__":
from myogen import simulator
from myogen.utils.plotting.muscle import show_mf_centers, show_innervation_areas_2d
import seaborn as sns
# Generate recruitment thresholds#
N_MU = 100 # Number of motor units
r = 4.9 # Radius of the muscle in mm
fiber_density = 400 # fibers per mm²
max_innervation_ratio = 1 / 4 # Maximum innervation area to total muscle area ratio
grid_resolution = 256 # Grid resolution for muscle simulation
recruitment_thresholds, recruitment_thresholds_starting_by_zeros = (
simulator.generate_mu_recruitment_thresholds(
N=N_MU,
recruitment_range=50, # Recruitment range in % MVC
mode="konstantin", # Recruitment pattern model
konstantin__max_threshold=1,
)
)
plt.plot(recruitment_thresholds, marker="o", linestyle="None")
plt.show()
# #Create muscle object
# muscle = simulator.Muscle(
# recruitment_thresholds=recruitment_thresholds,
# radius__mm=r,
# fiber_density__fibers_per_mm2=fiber_density,
# max_innervation_area_to_total_muscle_area__ratio=max_innervation_ratio,
# grid_resolution=grid_resolution,
# autorun=True,
# )
# joblib.dump(muscle, "muscle.pkl") # Save for reuse
# load muscle object
muscle = joblib.load("muscle.pkl")
with plt.xkcd():
plt.rcParams.update({"font.size": 24})
plt.rcParams.update({"axes.titlesize": 24})
plt.rcParams.update({"axes.labelsize": 24})
plt.rcParams.update({"xtick.labelsize": 24})
plt.rcParams.update({"ytick.labelsize": 24})
show_mf_centers(muscle, ax=plt.gca())
sns.despine(trim=True, offset=10)
plt.show()
plt.figure(figsize=(6, 6))
plt.rcParams.update({"font.size": 24})
plt.rcParams.update({"axes.titlesize": 24})
plt.rcParams.update({"axes.labelsize": 24})
plt.rcParams.update({"xtick.labelsize": 24})
plt.rcParams.update({"ytick.labelsize": 24})
selected_indices = np.array(list(reversed(range(120))))
with plt.xkcd():
show_innervation_areas_2d(
muscle, indices_to_plot=selected_indices, ax=plt.gca()
)
plt.title("Motor Unit Innervation Areas (First 10 MUs)")
plt.xlabel("X Position (mm)")
plt.ylabel("Y Position (mm)")
plt.axis("equal")
plt.show()
surface_emg = SurfaceEMG(
muscle_model=muscle,
MUs_to_simulate=[0],
)
MUAP = surface_emg.simulate_muaps(
verbose=True,
show_plots=False, # Center at the muscle belly
)
np.save("FDI_MUAPs_multi.npy", MUAP)
#######################################################################################################
###################################### References #####################################################
#######################################################################################################
# BOTELHO, Diego; CURRAN, Kathleen; LOWERY, Madeleine M. Anatomically accurate
# model of EMG during index finger flexion and abduction derived from diffusion tensor
# imaging. PLOS Computational Biology, [S. l.], v. 15, n. 8, p. e1007267, 2019. DOI:
# 10.1371/journal.pcbi.1007267.
# BRODAR, Vida. Observations on Skin Thickness and Subcutaneous Tissue in Man. Zeitschrift
# Für Morphologie Und Anthropologie, [S. l.], v. 50, n. 3, p. 386–95, 1960. Available in:
# https://about.jstor.org/terms
# ENOKA, Roger M.; FUGLEVAND, Andrew J. Motor unit physiology: Some unresolved issues. Muscle & Nerve,
# [S. l.], v. 24, n. 1, p. 4–17, 2001. DOI: 10.1002/1097-4598(200101)24:1<4::AID-MUS13>3.0.CO;2-F.
# FEINSTEIN, Bertram; LINDEGARD, Bengt; NYMAN, Eberhard; WOHLFART, Gunnar.
# MORPHOLOGIC STUDIES OF MOTOR UNITS IN NORMAL HUMAN MUSCLES. Cells Tissues
# Organs, [S. l.], v. 23, n. 2, p. 127–142, 1955. DOI: 10.1159/000140989.
# HWANG, Kun; HUAN, Fan; KIM, Dae Joong. Muscle fibre types of the lumbrical, interossei,
# flexor, and extensor muscles moving the index finger. Journal of Plastic Surgery and Hand
# Surgery, [S. l.], v. 47, n. 4, p. 268–272, 2013. DOI: 10.3109/2000656X.2012.755988.
# JACOBSON, Mark D.; RAAB, Rajnik; FAZELI, Babak M.; ABRAMS, Reid A.; BOTTE, Michael J.;
# LIEBER, Richard L. Architectural design of the human intrinsic hand muscles. The Journal of
# Hand Surgery, [S. l.], v. 17, n. 5, p. 804–809, 1992. DOI: 10.1016/0363-5023(92)90446-V.
# ROELEVELD, K.; BLOK, J. H.; STEGEMAN, D. F.; VAN OOSTEROM, A. Volume conduction
# models for surface EMG; confrontation with measurements. Journal of Electromyography
# and Kinesiology, [S. l.], v. 7, n. 4, p. 221–232, 1997. DOI: 10.1016/S1050-6411(97)00009-6.
# STÖRCHLE, Paul; MÜLLER, Wolfram; SENGEIS, Marietta; LACKNER, Sonja; HOLASEK, Sandra;
# FÜRHAPTER-RIEGER, Alfred. Measurement of mean subcutaneous fat thickness: eight
# standardised ultrasound sites compared to 216 randomly selected sites. Scientific Reports,
# [S. l.], v. 8, n. 1, p. 16268, 2018. DOI: 10.1038/s41598-018-34213-0.