"""
Electrode configuration framework for EMG simulation.
"""
from typing import Literal
import numpy as np
from scipy.spatial.transform import Rotation as R
from myogen.utils.types import beartowertype
[docs]
@beartowertype
class SurfaceElectrodeArray:
"""
Surface electrode array for EMG recording.
Represents a grid of surface electrodes with configurable spacing,
size, and differentiation modes.
Parameters
----------
num_rows : int
Number of rows in the electrode array
num_cols : int
Number of columns in the electrode array
inter_electrode_distances__mm : float
Inter-electrode distances in mm.
electrode_radius__mm : float, optional
Radius of the electrodes in mm
center_point__mm_deg : tuple[float, float]
Position along z in mm and rotation around the muscle theta in degrees.
bending_radius__mm : float, optional
Bending radius around which the electrode grid is bent. Usually this is equal to the radius of the muscle.
rotation_angle__deg : float, optional
Rotation angle of the electrodes in degrees. This is the angle between the electrode grid and the muscle surface.
differentiation_mode : {"monopolar", "bipolar_longitudinal", "bipolar_transversal", "laplacian"}
Differentiation mode. Default is monopolar.
Attributes
----------
pos_z : np.ndarray
Longitudinal electrode positions in mm, shape (num_rows, num_cols).
Available after class initialization via `_create_electrode_grid()`.
pos_theta : np.ndarray
Angular electrode positions in radians, shape (num_rows, num_cols).
Available after class initialization via `_create_electrode_grid()`.
electrode_positions : tuple[np.ndarray, np.ndarray]
Complete electrode position arrays (pos_z, pos_theta).
Available after class initialization via `_create_electrode_grid()`.
num_electrodes : int
Total number of electrodes (num_rows * num_cols).
num_channels : int
Number of recording channels based on differentiation mode.
"""
[docs]
def __init__(
self,
num_rows: int,
num_cols: int,
inter_electrode_distances__mm: float,
electrode_radius__mm: float,
center_point__mm_deg: tuple[float, float] = (0.0, 0.0),
bending_radius__mm: float = 0.0,
rotation_angle__deg: float = 0.0,
differentiation_mode: Literal[
"monopolar", "bipolar_longitudinal", "bipolar_transversal", "laplacian"
] = "monopolar",
):
# Immutable public arguments - never modify these
self.num_rows = num_rows
self.num_cols = num_cols
self.center_point__mm_deg = center_point__mm_deg
self.bending_radius__mm = bending_radius__mm
self.rotation_angle__deg = rotation_angle__deg
self.inter_electrode_distances__mm = inter_electrode_distances__mm
self.electrode_radius__mm = electrode_radius__mm
self.differentiation_mode = differentiation_mode
# Private copies for internal modifications
self._num_rows = num_rows
self._num_cols = num_cols
self._center_point__mm_deg = center_point__mm_deg
self._bending_radius__mm = bending_radius__mm
self._rotation_angle__deg = rotation_angle__deg
self._inter_electrode_distances__mm = inter_electrode_distances__mm
self._electrode_radius__mm = electrode_radius__mm
self._differentiation_mode = differentiation_mode
self.num_electrodes = num_rows * num_cols
# Handle zero bending radius
if self._bending_radius__mm == 0:
self._bending_radius__mm = np.finfo(np.float32).eps
# Set up channel configuration based on differentiation mode
if differentiation_mode == "monopolar":
self.num_channels = self.num_electrodes
elif differentiation_mode in ["bipolar_longitudinal", "bipolar_transversal"]:
# For bipolar, we lose one channel per dimension
if differentiation_mode == "bipolar_longitudinal":
self.num_channels = max(1, num_rows - 1) * num_cols
else: # bipolar_transversal
self.num_channels = num_rows * max(1, num_cols - 1)
elif differentiation_mode == "laplacian":
# For Laplacian, we lose border electrodes
self.num_channels = max(1, num_rows - 2) * max(1, num_cols - 2)
else:
self.num_channels = self.num_electrodes
# Create electrode grid in local coordinate system
self._create_electrode_grid()
def _create_electrode_grid(self) -> None:
"""Create electrode positions in local coordinate system.
Results are stored in the `electrode_positions` property after execution.
"""
_pos_z = np.zeros((self._num_rows, self._num_cols))
if self._num_rows % 2 == 1:
index_center = int((self._num_rows - 1) / 2)
_pos_z[index_center, :] = self._center_point__mm_deg[0]
for i in range(1, index_center + 1):
_pos_z[index_center + i, :] = (
_pos_z[index_center + i - 1, :]
+ self._inter_electrode_distances__mm
)
_pos_z[index_center - i, :] = (
_pos_z[index_center - i + 1, :]
- self._inter_electrode_distances__mm
)
else:
index_center1 = int(self._num_rows / 2)
index_center2 = index_center1 - 1
_pos_z[index_center1, :] = (
self._center_point__mm_deg[0] + self._inter_electrode_distances__mm / 2
)
_pos_z[index_center2, :] = (
self._center_point__mm_deg[0] - self._inter_electrode_distances__mm / 2
)
for i in range(1, index_center2 + 1):
_pos_z[index_center1 + i, :] = (
_pos_z[index_center1 + i - 1, :]
+ self._inter_electrode_distances__mm
)
_pos_z[index_center2 - i, :] = (
_pos_z[index_center2 - i + 1, :]
- self._inter_electrode_distances__mm
)
_pos_theta = np.zeros((self._num_rows, self._num_cols))
if self._num_cols % 2 == 1:
index_center = int((self._num_cols - 1) / 2)
_pos_theta[:, index_center] = self._center_point__mm_deg[1] * np.pi / 180
for i in range(1, index_center + 1):
_pos_theta[:, index_center + i] = (
_pos_theta[:, index_center + i - 1]
+ self._inter_electrode_distances__mm / self._bending_radius__mm
)
_pos_theta[:, index_center - i] = (
_pos_theta[:, index_center - i + 1]
- self._inter_electrode_distances__mm / self._bending_radius__mm
)
else:
index_center1 = int(self._num_cols / 2)
index_center2 = index_center1 - 1
_pos_theta[:, index_center1] = (
self._center_point__mm_deg[1] * np.pi / 180
+ self._inter_electrode_distances__mm / 2 / self._bending_radius__mm
)
_pos_theta[:, index_center2] = (
self._center_point__mm_deg[1] * np.pi / 180
- self._inter_electrode_distances__mm / 2 / self._bending_radius__mm
)
for i in range(1, index_center2 + 1):
_pos_theta[:, index_center1 + i] = (
_pos_theta[:, index_center1 + i - 1]
+ self._inter_electrode_distances__mm / self._bending_radius__mm
)
_pos_theta[:, index_center2 - i] = (
_pos_theta[:, index_center2 - i + 1]
- self._inter_electrode_distances__mm / self._bending_radius__mm
)
## Rotated detection system (Farina, 2004), eq (36)
displacement = self._center_point__mm_deg[0] * np.ones(_pos_z.shape)
_pos_z = (
-self._bending_radius__mm
* np.sin(self._rotation_angle__deg * np.pi / 180)
* _pos_theta
+ np.cos(self._rotation_angle__deg * np.pi / 180) * (_pos_z - displacement)
+ displacement
)
_pos_theta = (
np.cos(self._rotation_angle__deg * np.pi / 180) * _pos_theta
+ np.sin(self._rotation_angle__deg * np.pi / 180)
* (_pos_z - displacement)
/ self._bending_radius__mm
)
# Store results privately
self._pos_z = _pos_z
self._pos_theta = _pos_theta
@property
def pos_z(self) -> np.ndarray:
"""
Longitudinal positions of electrodes in mm.
Returns
-------
np.ndarray
Array of shape (num_rows, num_cols) containing z-coordinates
of each electrode position in mm.
Raises
------
AttributeError
If electrode grid has not been created. Run constructor first.
"""
if not hasattr(self, "_pos_z"):
raise AttributeError(
"Electrode grid not computed. This should be automatically created "
"during class initialization. Please check constructor execution."
)
return self._pos_z
@property
def pos_theta(self) -> np.ndarray:
"""
Angular positions of electrodes in radians.
Returns
-------
np.ndarray
Array of shape (num_rows, num_cols) containing angular coordinates
of each electrode position in radians.
Raises
------
AttributeError
If electrode grid has not been created. Run constructor first.
"""
if not hasattr(self, "_pos_theta"):
raise AttributeError(
"Electrode grid not computed. This should be automatically created "
"during class initialization. Please check constructor execution."
)
return self._pos_theta
@property
def electrode_positions(self) -> tuple[np.ndarray, np.ndarray]:
"""
Complete electrode position arrays (z, theta) in physical coordinates.
Returns
-------
tuple[np.ndarray, np.ndarray]
Tuple containing:
- pos_z: Longitudinal positions in mm, shape (num_rows, num_cols)
- pos_theta: Angular positions in radians, shape (num_rows, num_cols)
Raises
------
AttributeError
If electrode grid has not been created. Run constructor first.
"""
return (self.pos_z, self.pos_theta)
[docs]
def get_H_sf(
self, ktheta_mesh_kzktheta: np.ndarray, kz_mesh_kzktheta: np.ndarray
) -> np.ndarray | float:
"""
Get the spatial filter for the electrode array.
Parameters
----------
ktheta_mesh_kzktheta : np.ndarray
Angular spatial frequency mesh
kz_mesh_kzktheta : np.ndarray
Longitudinal spatial frequency mesh
Returns
-------
H_sf : np.ndarray or float
Spatial filter for the specified differentiation mode
"""
if self.differentiation_mode == "monopolar":
H_sf = 1.0
elif self.differentiation_mode == "bipolar_longitudinal":
# Differential along muscle fiber direction (z-axis)
# Apply coordinate transformation for rotation
alpha_rad = self.rotation_angle__deg * np.pi / 180
kz_new = ktheta_mesh_kzktheta / self.bending_radius__mm * np.sin(
alpha_rad
) + kz_mesh_kzktheta * np.cos(alpha_rad)
# Spatial filter for longitudinal differential
H_sf = np.exp(1j * kz_new) - np.exp(-1j * kz_new)
elif self.differentiation_mode == "bipolar_transversal":
# Differential around muscle circumference (theta-axis)
# Apply coordinate transformation for rotation
alpha_rad = self.rotation_angle__deg * np.pi / 180
ktheta_new = ktheta_mesh_kzktheta * np.cos(
alpha_rad
) - kz_mesh_kzktheta * self.bending_radius__mm * np.sin(alpha_rad)
# Spatial filter for transversal differential
H_sf = np.exp(1j * ktheta_new / self.bending_radius__mm) - np.exp(
-1j * ktheta_new / self.bending_radius__mm
)
elif self.differentiation_mode == "laplacian":
# Laplacian (second-order spatial differential)
# Combination of longitudinal and transversal second derivatives
alpha_rad = self.rotation_angle__deg * np.pi / 180
kz_new = ktheta_mesh_kzktheta / self.bending_radius__mm * np.sin(
alpha_rad
) + kz_mesh_kzktheta * np.cos(alpha_rad)
ktheta_new = ktheta_mesh_kzktheta * np.cos(
alpha_rad
) - kz_mesh_kzktheta * self.bending_radius__mm * np.sin(alpha_rad)
# Laplacian approximation: -k^2 in frequency domain
k_total_sq = kz_new**2 + (ktheta_new / self.bending_radius__mm) ** 2
H_sf = -k_total_sq
return H_sf
[docs]
@beartowertype
class IntramuscularElectrodeArray:
"""
Intramuscular electrode array for EMG recording.
Represents a linear array of intramuscular electrodes (needle electrodes)
with configurable spacing and differentiation modes.
Parameters
----------
num_electrodes : int
Number of electrodes in the array
inter_electrode_distance__mm : float, default=0.5
Inter-electrode distance in mm
position__mm : tuple[float, float, float], default=(0.0, 0.0, 0.0)
Position of the electrode array center in mm (x, y, z coordinates)
orientation__rad : tuple[float, float, float], default=(0.0, 0.0, 0.0)
Orientation of the electrode array in radians (roll, pitch, yaw)
differentiation_mode : Literal["consecutive", "reference"], default="consecutive"
Differentiation mode for recording
trajectory_distance__mm : float, default=0.0
Distance for trajectory movement in mm
trajectory_steps : int, default=1
Number of steps in the trajectory
Attributes
----------
electrode_positions : np.ndarray
Current electrode positions in 3D space, shape (n_nodes * num_electrodes, 3).
Available after set_linear_trajectory() execution.
differential_matrix : np.ndarray
Differential matrix for signal processing based on differentiation mode.
Available after class initialization.
trajectory_transforms : np.ndarray
Transformation matrices for trajectory movement, shape (n_nodes, 6).
Available after set_linear_trajectory() execution.
initial_positions : np.ndarray
Initial electrode positions after position/orientation setup, shape (num_electrodes, 3).
Available after set_position() execution.
num_channels : int
Number of recording channels based on differentiation mode.
Available after class initialization.
num_points : int
Alias for num_electrodes (compatibility).
n_nodes : int
Number of trajectory nodes.
"""
[docs]
def __init__(
self,
num_electrodes: int,
inter_electrode_distance__mm: float = 0.5,
position__mm: tuple[float, float, float] = (0.0, 0.0, 0.0),
orientation__rad: tuple[float, float, float] = (0.0, 0.0, 0.0),
differentiation_mode: Literal["consecutive", "reference"] = "consecutive",
trajectory_distance__mm: float = 0.0,
trajectory_steps: int = 1,
):
# Immutable public arguments - never modify these
self.num_electrodes = num_electrodes
self.inter_electrode_distance__mm = inter_electrode_distance__mm
self.position__mm = position__mm
self.orientation__rad = orientation__rad
self.differentiation_mode = differentiation_mode
self.trajectory_distance__mm = trajectory_distance__mm
self.trajectory_steps = trajectory_steps
# Private copies for internal modifications
self._num_electrodes = num_electrodes
self._inter_electrode_distance__mm = inter_electrode_distance__mm
self._position__mm = position__mm
self._orientation__rad = orientation__rad
self._differentiation_mode = differentiation_mode
self._trajectory_distance__mm = trajectory_distance__mm
self._trajectory_steps = trajectory_steps
self.num_points = num_electrodes # Alias for compatibility
self.n_nodes = trajectory_steps
self._pts_origin = np.concatenate(
[
np.zeros((self._num_electrodes, 2)),
np.arange(self._num_electrodes)[..., None]
* self._inter_electrode_distance__mm,
],
axis=-1,
)
self._normal_origin = []
self._normals_init = []
self._normals = []
match differentiation_mode:
case "consecutive":
eye_mat = np.eye(
self._pts_origin.shape[0] - 1, self._pts_origin.shape[0]
)
self._diff_mat = eye_mat - np.roll(eye_mat, shift=1, axis=1)
case "reference":
self._diff_mat = np.roll(
np.eye(self._pts_origin.shape[0] - 1, self._pts_origin.shape[0]),
shift=1,
axis=1,
)
self._diff_mat[:, 0] = -1
self._n_channels = self._diff_mat.shape[0]
self.set_position(
position__mm=self._position__mm, orientation__rad=self._orientation__rad
)
self.set_linear_trajectory(
distance__mm=self._trajectory_distance__mm, n_nodes=self._trajectory_steps
)
[docs]
def set_position(
self,
position__mm: tuple[float, float, float],
orientation__rad: tuple[float, float, float],
) -> None:
"""
Set the position and orientation of the intramuscular electrode array.
This method defines the spatial placement and angular orientation of the
electrode array within the muscle volume. The array is first oriented
according to the specified rotations and then translated to the target position.
**Coordinate System:**
- x-axis: radial direction (outward from muscle center)
- y-axis: circumferential direction (around muscle)
- z-axis: longitudinal direction (along muscle fibers)
**Rotation Order:**
Applied as: Roll (x) → Pitch (y) → Yaw (z) using Rodrigues rotation
Parameters
----------
position__mm : tuple[float, float, float]
Center position of the electrode array in mm (x, y, z coordinates).
This defines where the array center is placed within the muscle.
orientation__rad : tuple[float, float, float]
Orientation angles in radians (roll, pitch, yaw).
- Roll: rotation around x-axis (radial tilt)
- Pitch: rotation around y-axis (circumferential tilt)
- Yaw: rotation around z-axis (longitudinal rotation)
Notes
-----
Position and orientation changes affect all subsequent trajectory calculations.
The electrode positions are recalculated based on the new transformation.
Examples
--------
>>> # Place array at muscle center with 45° yaw rotation
>>> array.set_position(
... position__mm=(0.0, 0.0, 10.0),
... orientation__rad=(0.0, 0.0, np.pi/4)
... )
See Also
--------
set_linear_trajectory : Define trajectory movement parameters
rodrigues_rot : Rodrigues rotation implementation
"""
self._pts_init = np.copy(self._pts_origin)
self._pts_init = self.rodrigues_rot(
self._pts_init, [1, 0, 0], orientation__rad[0]
)
self._pts_init = self.rodrigues_rot(
self._pts_init, [0, 1, 0], orientation__rad[1]
)
self._pts_init = self.rodrigues_rot(
self._pts_init, [0, 0, 1], orientation__rad[2]
)
self._pts_init += np.matlib.repmat(
np.array(position__mm)[None], self._pts_init.shape[0], 1
)
self._pts = np.copy(self._pts_init)
[docs]
def rodrigues_rot(self, v, k, theta):
"""
Apply Rodrigues rotation to vectors around an arbitrary axis.
This method implements 3D rotation of points or vectors around an arbitrary
axis using the Rodrigues rotation formula. It is used internally for
electrode array positioning and trajectory calculations.
**Mathematical Foundation:**
Based on Rodrigues' rotation formula for rotating a vector v around
axis k by angle theta: v_rot = v*cos(θ) + (k×v)*sin(θ) + k*(k·v)*(1-cos(θ))
Parameters
----------
v : array_like
Vector(s) to rotate. Can be single vector (3,) or array of vectors (N, 3).
k : array_like
Rotation axis vector (3,). Will be normalized internally.
theta : float
Rotation angle in radians. Positive angles follow right-hand rule.
Returns
-------
np.ndarray
Rotated vector(s) with same shape as input v.
Notes
-----
Uses scipy.spatial.transform.Rotation for numerical stability and efficiency.
The rotation axis k is automatically normalized to unit length.
Examples
--------
>>> # Rotate point 90° around z-axis
>>> point = np.array([1.0, 0.0, 0.0])
>>> rotated = array.rodrigues_rot(point, [0, 0, 1], np.pi/2)
>>> # Result: approximately [0, 1, 0]
"""
v = np.array(v.copy(), dtype=float)
k = np.array(k.copy(), dtype=float)
k = k / np.linalg.norm(k) # normalize axis
r = R.from_rotvec(k * theta) # Create rotation from axis-angle
return r.apply(v) # Rotates v (works with (3,), (N, 3))
[docs]
def set_linear_trajectory(
self, distance__mm: float, n_nodes: int | None = None
) -> None:
"""
Configure linear trajectory movement for the electrode array.
This method sets up a linear movement path for the electrode array,
simulating needle insertion or withdrawal. The trajectory is discretized
into nodes for temporal interpolation during EMG simulation.
**Trajectory Properties:**
- Direction: Along the array's longitudinal axis (z-direction in local coordinates)
- Movement: Linear progression from start to end position
- Discretization: Evenly spaced nodes for smooth interpolation
- Default step size: 0.5mm if n_nodes not specified
Parameters
----------
distance__mm : float
Total trajectory distance in mm. Positive values move in the
positive z-direction of the array's local coordinate system.
n_nodes : int, optional
Number of discrete trajectory nodes. If None, automatically
calculated as max(ceil(distance/0.5), 1) for 0.5mm steps.
Notes
-----
The trajectory is applied after position and orientation transformations.
All trajectory transforms are calculated in the array's oriented coordinate system.
Examples
--------
>>> # Set up 10mm insertion with default step size (~0.5mm)
>>> array.set_linear_trajectory(distance__mm=10.0)
>>> # Set up 5mm trajectory with specific number of nodes
>>> array.set_linear_trajectory(distance__mm=5.0, n_nodes=20)
See Also
--------
calc_observation_points : Calculate electrode positions along trajectory
traj_mixing_mat : Generate mixing matrices for trajectory interpolation
"""
if n_nodes is None:
n_nodes = max(np.ceil(distance__mm / 0.5), 1)
self.n_nodes = n_nodes
self._trajectory_step = distance__mm / self.n_nodes
self.traj_transforms = np.linspace(start=0, stop=distance__mm, num=self.n_nodes)
self.traj_transforms = np.hstack(
[
np.zeros((max(self.traj_transforms.shape), 2)),
self.traj_transforms.reshape(-1, 1),
np.zeros((max(self.traj_transforms.shape), 3)),
]
)
self.traj_transforms[:, :3] = self.rodrigues_rot(
self.traj_transforms[:, :3], [1, 0, 0], self.orientation__rad[0]
)
self.traj_transforms[:, :3] = self.rodrigues_rot(
self.traj_transforms[:, :3], [0, 1, 0], self.orientation__rad[1]
)
self.traj_transforms[:, :3] = self.rodrigues_rot(
self.traj_transforms[:, :3], [0, 0, 1], self.orientation__rad[2]
)
self.calc_observation_points()
[docs]
def calc_observation_points(self) -> None:
self.pts = np.concatenate(
[
self.rotate_and_translate(
self._pts_init,
self.traj_transforms[i, 3:],
self.traj_transforms[i, :3],
)
for i in range(self.n_nodes)
]
)
self._diff_mat = np.matlib.repmat(self._diff_mat, 1, self.n_nodes)
[docs]
def rotate_and_translate(self, pt, rpy, d):
# pt: (N, 3) matrix
# rpy: (3,) vector [roll, pitch, yaw]
# d: (3,) vector translation
pt = self.rodrigues_rot(pt, np.array([1, 0, 0]), rpy[0]) # roll
pt = self.rodrigues_rot(pt, np.array([0, 1, 0]), rpy[1]) # pitch
pt = self.rodrigues_rot(pt, np.array([0, 0, 1]), rpy[2]) # yaw
return pt + d.reshape(1, 3) # translation (broadcasted)
[docs]
def traj_mixing_fun(self, t, n_nodes, node) -> float:
"""
Compute mixing weight for a specific trajectory node at given time.
This function calculates the interpolation weight for a trajectory node based
on the current time/position along the trajectory. Uses triangular weighting
where nodes closer to the current time get higher weights.
Parameters
----------
t : float
Current normalized time or position in trajectory (0.0 to 1.0).
n_nodes : int
Total number of nodes in the trajectory.
node : int or array_like
Node index(es) for which to calculate mixing weights.
Can be scalar or array of node indices.
Returns
-------
float or np.ndarray
Mixing weight(s) for the specified node(s) at time t.
Returns 0 for distant nodes, max weight 1 for closest node.
"""
eps = np.finfo(float).eps
return np.maximum(
0,
1 - (n_nodes - 1) * np.abs(t - (node - 1) / (n_nodes - 1 + eps)),
)
[docs]
def traj_mixing_mat(self, t, n_nodes, n_channels) -> np.ndarray:
"""
Generate mixing matrix for trajectory interpolation during EMG simulation.
This method creates a diagonal mixing matrix that weights the contribution of
different trajectory nodes during temporal interpolation. The matrix enables
smooth transitions between electrode positions as the array moves along its
trajectory during needle insertion or withdrawal.
**Interpolation Strategy:**
- Linear interpolation between adjacent trajectory nodes
- Weights based on distance from current time/position to node positions
- Diagonal matrix structure for efficient computation
- Smooth transitions avoid discontinuities in EMG signals
Parameters
----------
t : float
Current normalized time or position in trajectory (0.0 to 1.0).
0.0 corresponds to trajectory start, 1.0 to trajectory end.
n_nodes : int
Total number of trajectory nodes for interpolation.
n_channels : int
Number of recording channels in the electrode array.
Depends on differentiation mode and electrode count.
Returns
-------
np.ndarray
Diagonal mixing matrix with shape (n_nodes * n_channels, n_nodes * n_channels).
Diagonal elements contain repeated mixing weights for each trajectory node,
with each node's weight repeated n_channels times.
Notes
-----
The mixing matrix enables temporal interpolation of EMG signals recorded
at different trajectory positions. Higher weights are given to nodes
closer to the current time/position parameter.
Examples
--------
>>> # Get mixing weights for mid-trajectory position
>>> mix_mat = array.traj_mixing_mat(t=0.5, n_nodes=10, n_channels=4)
>>> # Matrix will weight middle nodes more heavily
See Also
--------
traj_mixing_fun : Individual node mixing function
set_linear_trajectory : Configure trajectory parameters
"""
return np.diag(
np.repeat(
self.traj_mixing_fun(t, n_nodes, np.arange(1, n_nodes + 1)),
n_channels,
)
)
@property
def electrode_positions(self) -> np.ndarray:
"""
Current electrode positions in 3D space (mm).
Returns
-------
np.ndarray
Array of shape (n_nodes * num_electrodes, 3) containing x, y, z coordinates
of each electrode position for all trajectory nodes.
Raises
------
AttributeError
If trajectory has not been calculated. Run set_linear_trajectory() first.
"""
if not hasattr(self, "pts"):
raise AttributeError(
"Electrode positions not computed. Run set_linear_trajectory() first "
"to calculate trajectory and electrode positions."
)
return self.pts
@property
def differential_matrix(self) -> np.ndarray:
"""
Differential matrix for signal processing based on differentiation mode.
Returns
-------
np.ndarray
Differential matrix for applying spatial differentiation to recorded signals.
Shape depends on differentiation mode and number of trajectory nodes.
Raises
------
AttributeError
If differential matrix has not been created. Run constructor first.
"""
if not hasattr(self, "_diff_mat"):
raise AttributeError(
"Differential matrix not computed. This should be automatically created "
"during class initialization. Please check constructor execution."
)
return self._diff_mat
@property
def trajectory_transforms(self) -> np.ndarray:
"""
Transformation matrices for trajectory movement.
Returns
-------
np.ndarray
Array of shape (n_nodes, 6) containing translation and rotation parameters
for each trajectory node. First 3 columns are translations (x, y, z),
last 3 columns are rotations (roll, pitch, yaw).
Raises
------
AttributeError
If trajectory has not been set. Run set_linear_trajectory() first.
"""
if not hasattr(self, "traj_transforms"):
raise AttributeError(
"Trajectory transforms not computed. Run set_linear_trajectory() first "
"to configure electrode trajectory movement."
)
return self.traj_transforms
@property
def initial_positions(self) -> np.ndarray:
"""
Initial electrode positions after position/orientation setup.
Returns
-------
np.ndarray
Array of shape (num_electrodes, 3) containing initial x, y, z coordinates
of electrodes before trajectory movement is applied.
Raises
------
AttributeError
If initial positions have not been set. Run set_position() first.
"""
if not hasattr(self, "_pts_init"):
raise AttributeError(
"Initial electrode positions not computed. Run set_position() first "
"to configure electrode array placement and orientation."
)
return self._pts_init
@property
def num_channels(self) -> int:
"""
Number of recording channels based on differentiation mode.
Returns
-------
int
Number of differential recording channels available from this electrode array.
Raises
------
AttributeError
If channel count has not been calculated. Run constructor first.
"""
if not hasattr(self, "_n_channels"):
raise AttributeError(
"Number of channels not computed. This should be automatically created "
"during class initialization. Please check constructor execution."
)
return self._n_channels