Files

737 lines
30 KiB
Python

from abc import ABC, abstractmethod
from enum import Enum
import numpy as np
from typing import Tuple, Optional
class ArrayType(Enum):
"""Enumeration of supported antenna array types."""
ULA = 'ula'
URA = 'ura'
UCA = 'uca'
NESTED = 'nested'
COPRIME = 'coprime'
MRA = 'mra'
CYLINDRICAL = "cylindrical"
CUSTOM = 'custom'
class BaseArray(ABC):
"""Abstract base class for antenna arrays.
Attributes:
positions (np.ndarray): Physical coordinates of each element, shape (dim, M).
- dim: Spatial dimension of the array (1 for linear, 2 for planar, etc.)
- M: Number of antenna elements
type (ArrayType): Array geometry type (e.g., ULA, URA, UCA, etc.).
spacing (Optional[float]): Inter-element spacing, if uniformly defined.
dim (int): Spatial dimension, inferred from positions.shape[0].
count (int): Total number of elements, inferred from positions.shape[1].
indices (Optional[np.ndarray]): Structured grid indices, shape (dim, M), used in structured arrays like URA.
position_errors (np.ndarray): Perturbations in element positions, same shape as `positions`.
gain_errors (np.ndarray): Per-element gain variation factors (linear scale), same shape as `positions`.
phase_errors (np.ndarray): Per-element phase offsets (in radians), same shape as `positions`.
"""
def __init__(
self,
positions: np.ndarray,
type: ArrayType,
spacing: Optional[float] = None,
indices: Optional[np.ndarray] = None,
position_errors: Optional[np.ndarray] = None,
gain_errors: Optional[np.ndarray] = None,
phase_errors: Optional[np.ndarray] = None
):
"""Initialize a base antenna array.
Args:
positions (np.ndarray): Element positions, shape (dim, M), where:
- dim is the spatial dimension (1, 2, or 3),
- M is the number of elements.
type (ArrayType): Type of the array structure (e.g., ArrayType.ULA, ArrayType.URA).
spacing (Optional[float]): Inter-element spacing, relevant only for structured arrays.
indices (Optional[np.ndarray]): Grid-based indices, same shape as `positions`.
position_errors (Optional[np.ndarray]): Position perturbations, same shape as `positions`.
gain_errors (Optional[np.ndarray]): Gain perturbations (linear), same shape as `positions`.
phase_errors (Optional[np.ndarray]): Phase perturbations (in radians), same shape as `positions`.
Raises:
ValueError: If input arrays have mismatched shapes or invalid dimensions.
"""
# element positions
positions = np.array(positions, dtype=float)
if positions.ndim == 1:
positions = positions.reshape(1, -1) # reshape to row vector
if positions.shape[0] not in (1, 2, 3):
raise ValueError(f"positions must be in 1D, 2D, or 3D space. Got dim={positions.shape[0]}.")
self.positions = positions
self.dim = positions.shape[0]
self.count = positions.shape[1]
# index
if indices is not None:
indices = np.array(indices)
if indices.shape != positions.shape:
raise ValueError(f"Shape mismatch: indices {indices.shape} vs positions {positions.shape}")
self.indices = indices
# errors and perturbations
# TODO: mutual coupling needs to be modeled
# TODO: if position error is provided, the dimension of the array should change w.s.t. the dimension of the error
if position_errors is not None:
position_errors = np.array(position_errors, dtype=float)
if position_errors.shape != positions.shape:
raise ValueError(f"Shape mismatch: position_errors {position_errors.shape} vs positions {positions.shape}")
self.position_errors = position_errors
else:
self.position_errors = position_errors # None
if gain_errors is not None:
gain_errors = np.array(gain_errors, dtype=float)
if gain_errors.shape != positions.shape:
raise ValueError(f"Shape mismatch: gain_errors {gain_errors.shape} vs positions {positions.shape}")
self.gain_errors = gain_errors
else:
self.gain_errors = gain_errors # None
if phase_errors is not None:
phase_errors = np.array(phase_errors, dtype=float)
if phase_errors.shape != positions.shape:
raise ValueError(f"Shape mismatch: phase_errors {phase_errors.shape} vs positions {positions.shape}")
self.phase_errors = phase_errors
else:
self.phase_errors = phase_errors # None
# other attributes
self.spacing = spacing
self.type = type
@abstractmethod
def steering_matrix(
self,
wavelength: float,
directions: np.ndarray
) -> np.ndarray:
"""Computes the array steering matrix for specified direction(s).
Args:
wavelength (float): Wavelength of the incoming signal (same unit as element positions).
directions (np.ndarray): Incident direction(s) in radians. Shape should be:
- (dim,) for a single direction
- (dim, D) for D directions
Returns:
np.ndarray: Complex-valued steering matrix of shape (M, D), where M is the number of elements
and D is the number of directions. For a single direction, shape is (M,).
Raises:
ValueError: If `wavelength` is non-positive.
ValueError: If `directions` shape is incompatible with array dimension.
"""
pass
@abstractmethod
def rotate(
self,
target_normal: np.ndarray,
current_normal: Optional[np.ndarray] = None,
center: Optional[np.ndarray] = None
) -> "BaseArray":
"""Rotate the array such that its current normal vector aligns with a target direction.
This method performs a rigid-body rotation around a given center (default: centroid):
1. Translates the array such that the rotation center lies at the origin.
2. Applies the rotation that maps `current_normal` to `target_normal`.
3. Translates the array back to its centroid or specified center.
Args:
target_normal (np.ndarray): Target normal vector, shape (dim,). Must be non-zero.
current_normal (Optional[np.ndarray]): Current normal vector, (shape (dim,).
If None, defaults to the vector from centroid to origin.
center (Optional[np.ndarray]): Optional rotation center, shape (dim,).
If None, uses the centroid of the array.
Returns:
BaseArray: A new array instance with rotated element positions (and other attributes if applicable).
Raises:
ValueError: If `target_normal` or `current_normal` has incorrect shape or is near-zero in norm.
NotImplementedError: If the method is not implemented in a concrete subclass.
"""
pass
def translate(self, offset: np.ndarray):
"""Translate the array to a new center by applying the given offset.
Args:
offset (np.ndarray): Translation vector of shape (dim,) or (1, dim).
Raises:
ValueError: If the dimension of the offset does not match the array's spatial dimension.
"""
if offset.ndim == 1 and offset.shape[0] == self.dim:
offset = np.reshape(offset, (-1, 1))
elif offset.ndim == 2:
if offset.shape == (1, self.dim):
offset = np.reshape(offset, (self.dim, 1))
elif offset.shape == (self.dim, 1):
pass # already correct shape
else:
raise ValueError(
f"Offset must be convertible to shape ({self.dim}, 1), "
f"but got shape {offset.shape}."
)
else:
raise ValueError(
f"Offset must be a 1D or 2D array convertible to shape ({self.dim}, 1), "
f"but got shape {offset.shape}."
)
self.positions += offset
def center(self):
"""Center the array at the origin."""
centroid = np.mean(self.positions, axis=1)
self.translate(-centroid)
class Array1D(BaseArray):
"""
One-dimensional linear array.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.dim != 1:
raise ValueError(f"Array1D requires 1D array. Got dimension = {self.dim}.")
def steering_matrix(
self,
wavelength: float,
directions: np.ndarray,
compute_derivative: bool = False
) -> Tuple[np.ndarray, Optional[np.ndarray]]:
"""Compute the steering matrix of 1D array.
Args:
wavelength (float): Signal wavelength.
directions (np.ndarray): Input directions, must be in one of the following formats:
- shape (D,) or (1, D): D broadside angles (in radians), valid for 1D array
- shape (2, D): D pairs of azimuth and elevation angles (in radians), used for projection to broadside
The final broadside angle is computed as asin(sin(az) * cos(el)).
compute_derivative (bool): Whether to compute the derivative of the steering matrix w.r.t. each DOA (only valid for 1D DOAs).
Returns:
A (np.ndarray): Steering matrix of shape (M, D)
DA (np.ndarray or None): Derivative matrix (optional, only valid for 1D DOAs)
Raises:
ValueError: If dimension of directions is not 1 or 2.
"""
directions = np.array(directions)
if directions.ndim in (0, 1):
directions = np.reshape(directions, (1, -1))
elif directions.ndim == 2:
if directions.shape[0] != 2:
raise ValueError(
f"If directions is 2D, its shape must be (2, D), "
f"but got shape {directions.shape}."
)
az, el = directions[0, :], directions[1, :]
directions = np.arcsin(np.sin(az) * np.cos(el))
else:
raise ValueError(
f"Invalid shape for directions ({directions.ndim}). "
"Expected shape (D,) or (1, D) for 1D broadside angles or shape (2, D) for azimuth/elevation."
)
DA = None
positions = self.positions
k = 2 * np.pi / wavelength
phase = k * (np.outer(positions, np.sin(directions)))
A = np.exp(1j * phase)
if compute_derivative:
if directions.ndim != 1:
raise ValueError("compute_derivative is only valid for 1D DOAs (broadside angles).")
DA = 1j * 2 * np.pi / wavelength * np.outer(positions, np.cos(directions)) * A
# apply gain/phase errors
if self.gain_errors is not None:
A_gain = self.gain_errors.flatten()[:, None]
A *= A_gain
if DA is not None:
DA *= A_gain
if self.phase_errors is not None:
A_phase = np.exp(1j * self.phase_errors.flatten())[:, None]
A *= A_phase
if DA is not None:
DA *= A_phase
return A, DA
def rotate(
self,
target_normal: np.ndarray,
current_normal: Optional[np.ndarray] = None,
center: Optional[np.ndarray] = None
) -> Tuple[BaseArray, np.ndarray]:
"""
Rotate a 1D array to align its normal vector with a target direction.
The array is assumed to be initially aligned along the y-axis (in 2D) or y-direction
in the xOy plane (in 3D). The method performs a rigid-body rotation to align the
array's normal with the specified `target_normal`.
The rotation is performed in three steps:
1. Translate the centroid of the array to the origin.
2. Apply the rotation based on the angle between current and target normal vectors.
3. Translate the array back to the specified or original centroid.
Args:
target_normal (np.ndarray): Target direction to align the array's normal to.
Must be a 2D or 3D vector of shape (2,) or (3,).
current_normal (Optional[np.ndarray]): Original normal direction of the array.
Defaults to [1, 0] in 2D and [0, 0, 1] in 3D.
center (Optional[np.ndarray]): Rotation center, defaults to the array's centroid.
Must be the same dimension as `target_normal`.
Returns:
Tuple[BaseArray, np.ndarray]:
- A new rotated array object (Array2D or Array3D) with updated element positions.
- The rotation matrix used in the transformation (2x2 or 3x3).
Raises:
ValueError: If `target_normal` is not 2D or 3D.
ValueError: If `center` is provided but its dimension does not match `target_normal`.
"""
target_normal = (np.array(target_normal, dtype=float)).flatten()
target_dim = target_normal.shape[0]
if target_dim not in (2, 3):
raise ValueError(f"Target normal must be a 2D or 3D vector, got shape {target_normal.shape}.")
positions = self.positions
elem_num = self.count
# Determine rotation center
if center is None:
center = np.mean(positions, axis=1)
center = np.reshape(np.array(center, dtype=float), (-1, 1))
if center.shape[0] != positions.shape[0]:
raise ValueError(
f"Array center and element positions must have the same dimension. "
f"Got center dim = {center.shape[0]}, positions dim = {positions.shape[0]}."
)
# Centered positions aligned along y-axis
if target_dim == 2:
# Arrange elements along Y-axis and center at origin
centered_pos = np.vstack((np.zeros((1, elem_num)), positions - center))
# Get normal vector
if current_normal is None:
current_normal = np.array([1.0, 0.0]) # assume
else:
current_normal = (np.array(current_normal, dtype=float)).flatten()
current_dim = current_normal.shape[0]
if current_dim != target_dim:
raise ValueError(f"Dimension of target normal ({target_dim}) and current normal ({current_dim}) must match.")
current_normal = current_normal / np.linalg.norm(current_normal)
target_normal = target_normal / np.linalg.norm(target_normal)
# Get the angle between current and target normal vector
angle = np.arctan2(target_normal[1], target_normal[0]) - np.arctan2(current_normal[1], current_normal[0])
rot_mat = np.array([
[np.cos(angle), -np.sin(angle)],
[np.sin(angle), np.cos(angle)]
])
# expand the dimension of rotation center
center_exp = np.vstack([
np.zeros((1, 1)),
center.reshape(1, 1)
])
# perform rotation and translation
positions_rot = rot_mat @ centered_pos + center_exp
return Array2D(
positions = positions_rot,
type = self.type,
spacing = self.spacing,
indices = self.indices,
position_errors = self.position_errors,
gain_errors = self.gain_errors,
phase_errors = self.phase_errors
), rot_mat
elif target_dim == 3:
# Arrange elements along y-axis and center at origin
centered_pos = np.vstack((
np.zeros((1, elem_num)),
positions - center,
np.zeros((1, elem_num))
))
# Get normal vector
if current_normal is None:
current_normal = np.array([0, 0, 1])
else:
current_normal = (np.array(current_normal, dtype=float)).flatten()
current_dim = current_normal.shape[0]
if current_dim != target_dim:
raise ValueError(f"Dimension of target normal ({target_dim}) and current normal ({current_dim}) must match.")
current_normal = current_normal / np.linalg.norm(current_normal)
target_normal = target_normal / np.linalg.norm(target_normal)
# Rotate to a 3D direction using axis-angle method
axis = np.cross(current_normal, target_normal)
norm_axis = np.linalg.norm(axis)
if norm_axis < 1e-8:
# If vectors are parallel or anti-parallel
if np.dot(current_normal, target_normal) > 0:
rot_mat = np.eye(3) # No rotation
else:
rot_mat = np.diag([-1, -1, 1]) # 180-degree rotation
else:
axis = axis / norm_axis
angle = np.arccos(np.dot(current_normal, target_normal))
K = np.array([
[0, -axis[2], axis[1]],
[axis[2], 0, -axis[0]],
[-axis[1], axis[0], 0]
])
rot_mat = np.eye(3) + np.sin(angle) * K + (1 - np.cos(angle)) * K @ K
# expand the dimension of rotation center
center_exp = np.vstack([
np.zeros((1, 1)),
center.reshape(1, 1),
np.zeros((1, 1))
])
# perform rotation and translation
positions_rot = rot_mat @ centered_pos + center
return Array3D(
positions = positions_rot,
type = self.type,
spacing = self.spacing,
indices = self.indices,
position_errors = self.position_errors,
gain_errors = self.gain_errors,
phase_errors = self.phase_errors
), rot_mat
else:
raise ValueError("Invalid dimension")
class Array2D(BaseArray):
"""
Two-dimensional planar array.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.dim != 2:
raise ValueError(f"Array2D requires 2D array. Got dimension = {self.dim}.")
def steering_matrix(
self,
wavelength: float,
directions: np.ndarray,
) -> np.ndarray:
"""Compute the steering matrix of 2D array.
Args:
wavelength (float): Signal wavelength.
directions (np.ndarray): Input directions, must be in one of the following formats:
- shape (2, D): D pairs of azimuth and elevation angles (in radians), used for projection to broadside
Returns:
A (np.ndarray): Steering matrix of shape (M, D)
Raises:
ValueError: If dimension of directions is 2.
"""
directions = np.array(directions)
if directions.ndim == 2:
if directions.shape[0] != 2:
raise ValueError(
f"If directions is 2D, its shape must be (2, D), "
f"but got shape {directions.shape}."
)
az, el = directions[0, :], directions[1, :]
cos_el = np.cos(el)
x_proj = cos_el * np.cos(az)
y_proj = cos_el * np.sin(az)
else:
raise ValueError(
f"Invalid shape for directions ({directions.ndim}). "
"Expected shape (2, D) for azimuth/elevation."
)
positions = self.positions
# Compute phase shifts
k = 2 * np.pi / wavelength
phase = k * (positions[0, :, None] * x_proj + positions[1, :, None] * y_proj)
A = np.exp(1j * phase)
# apply gain/phase errors
if self.gain_errors is not None:
A *= self.gain_errors.flatten()
if self.phase_errors is not None:
A *= np.exp(1j * self.phase_errors.flatten())
return A
def rotate(
self,
target_normal: np.ndarray,
current_normal: Optional[np.ndarray] = None,
center: Optional[np.ndarray] = None
) -> BaseArray:
"""
Rotate a 2D array to align its normal vector with a target direction.
The array is assumed to be initially aligned along xOy plane (in 3D). The method performs a rigid-body rotation to align the
array's normal with the specified `target_normal`.
The rotation is performed in three steps:
1. Translate the centroid of the array to the origin.
2. Apply the rotation based on the angle between current and target normal vectors.
3. Translate the array back to the specified or original centroid.
Args:
target_normal (np.ndarray): Target direction to align the array's normal to.
Must be a 3D vector of shape (3,).
current_normal (Optional[np.ndarray]): Original normal direction of the array.
Defaults to [0, 0, 1] in 3D.
center (Optional[np.ndarray]): Rotation center, defaults to the array's centroid.
Must be the same dimension as `target_normal`.
Returns:
Tuple[BaseArray, np.ndarray]:
- A new rotated array object (Array3D) with updated element positions.
- The rotation matrix used in the transformation (3x3).
Raises:
ValueError: If `target_normal` is not 3D.
ValueError: If `center` is provided but its dimension does not match `target_normal`.
"""
target_normal = (np.array(target_normal, dtype=float)).flatten()
target_dim = target_normal.shape[0]
if target_dim != 3:
raise ValueError(f"Target normal must be a 3D vector for 2D array, got shape {target_normal.shape}.")
positions = self.positions
elem_num = self.count
# Determine rotation center
if center is None:
center = np.mean(positions, axis=1)
center = np.reshape(np.array(center, dtype=float), (-1, 1))
if center.shape[0] != positions.shape[0]:
raise ValueError(
f"Array center and element positions must have the same dimension. "
f"Got center dim = {center.shape[0]}, positions dim = {positions.shape[0]}."
)
# Arrange elements along xOy plane and center at origin
centered_pos = np.vstack((
positions - center,
np.zeros((1, elem_num))
))
# Get normal vector
if current_normal is None:
current_normal = np.array([0, 0, 1])
else:
current_normal = (np.array(current_normal, dtype=float)).flatten()
current_dim = current_normal.shape[0]
if current_dim != target_dim:
raise ValueError(f"Dimension of target normal ({target_dim}) and current normal ({current_dim}) must match.")
current_normal = current_normal / np.linalg.norm(current_normal)
target_normal = target_normal / np.linalg.norm(target_normal)
# Rotate to a 3D direction using axis-angle method
axis = np.cross(current_normal, target_normal)
norm_axis = np.linalg.norm(axis)
if norm_axis < 1e-8:
# If vectors are parallel or anti-parallel
if np.dot(current_normal, target_normal) > 0:
rot_mat = np.eye(3) # No rotation
else:
rot_mat = np.diag([-1, -1, 1]) # 180-degree rotation
else:
axis = axis / norm_axis
angle = np.arccos(np.dot(current_normal, target_normal))
K = np.array([
[0, -axis[2], axis[1]],
[axis[2], 0, -axis[0]],
[-axis[1], axis[0], 0]
])
rot_mat = np.eye(3) + np.sin(angle) * K + (1 - np.cos(angle)) * K @ K
# expand the dimension of rotation center
center_exp = np.vstack([
center.reshape(2, 1),
np.zeros((1, 1))
])
# perform rotation and translation
positions_rot = rot_mat @ centered_pos + center_exp
return Array3D(
positions = positions_rot,
type = self.type,
spacing = self.spacing,
indices = self.indices,
position_errors = self.position_errors,
gain_errors = self.gain_errors,
phase_errors = self.phase_errors
), rot_mat
class Array3D(BaseArray):
"""
Three-dimensional volumetric array.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
assert self.dim == 3, "Array3D requires 3D element positions."
def steering_matrix(
self,
wavelength: float,
directions: np.ndarray,
) -> np.ndarray:
"""Compute the steering matrix of 2D array.
Args:
wavelength (float): Signal wavelength.
directions (np.ndarray): Input directions, must be in one of the following formats:
- shape (2, D): D pairs of azimuth and elevation angles (in radians), used for projection to broadside
Returns:
A (np.ndarray): Steering matrix of shape (M, D)
Raises:
ValueError: If dimension of directions is 2.
"""
directions = np.array(directions)
if directions.ndim == 2:
if directions.shape[0] != 2:
raise ValueError(
f"If directions is 2D, its shape must be (2, D), "
f"but got shape {directions.shape}."
)
az, el = directions[0, :], directions[1, :]
cos_el = np.cos(el)
x_proj = cos_el * np.cos(az)
y_proj = cos_el * np.sin(az)
z_proj = np.sin(el)
else:
raise ValueError(
f"Invalid shape for directions ({directions.ndim}). "
"Expected shape (2, D) for azimuth/elevation."
)
positions = self.positions
# Compute phase shifts
k = 2 * np.pi / wavelength
phase = k * (positions[0, :, None] * x_proj + positions[1, :, None] * y_proj
+ positions[2, :, None] * z_proj)
A = np.exp(1j * phase)
# apply gain/phase errors
if self.gain_errors is not None:
A *= self.gain_errors.flatten()
if self.phase_errors is not None:
A *= np.exp(1j * self.phase_errors.flatten())
return A
def rotate(
self,
target_normal: np.ndarray,
current_normal: Optional[np.ndarray] = None,
center: Optional[np.ndarray] = None
) -> BaseArray:
"""
Rotate a 3D array to align its normal vector with a target direction.
The method performs a rigid-body rotation to align the array's normal with the specified `target_normal`.
The rotation is performed in three steps:
1. Translate the centroid of the array to the origin.
2. Apply the rotation based on the angle between current and target normal vectors.
3. Translate the array back to the specified or original centroid.
Args:
target_normal (np.ndarray): Target direction to align the array's normal to.
Must be a 3D vector of shape (3,).
current_normal (Optional[np.ndarray]): Original normal direction of the array.
Defaults to [0, 0, 1] in 3D.
center (Optional[np.ndarray]): Rotation center, defaults to the array's centroid.
Must be the same dimension as `target_normal`.
Returns:
Tuple[BaseArray, np.ndarray]:
- A new rotated array object (Array3D) with updated element positions.
- The rotation matrix used in the transformation (3x3).
Raises:
ValueError: If `target_normal` is not 3D.
ValueError: If `center` is provided but its dimension does not match element `positions`.
"""
target_normal = (np.array(target_normal, dtype=float)).flatten()
target_dim = target_normal.shape[0]
if target_dim != 3:
raise ValueError(f"Target normal must be a 3D vector for 2D array, got shape {target_normal.shape}.")
positions = self.positions
elem_num = self.count
# Determine rotation center
if center is None:
center = np.mean(positions, axis=1)
center = np.reshape(np.array(center, dtype=float), (-1, 1))
if center.shape[0] != positions.shape[0]:
raise ValueError(
f"Array center and element positions must have the same dimension. "
f"Got center dim = {center.shape[0]}, positions dim = {positions.shape[0]}."
)
# Arrange elements along xOy plane and center at origin
centered_pos = positions - center
# Get normal vector
if current_normal is None:
current_normal = np.array([0, 0, 1])
else:
current_normal = (np.array(current_normal, dtype=float)).flatten()
current_dim = current_normal.shape[0]
if current_dim != target_dim:
raise ValueError(f"Dimension of target normal ({target_dim}) and current normal ({current_dim}) must match.")
current_normal = current_normal / np.linalg.norm(current_normal)
target_normal = target_normal / np.linalg.norm(target_normal)
# Rotate to a 3D direction using axis-angle method
axis = np.cross(current_normal, target_normal)
norm_axis = np.linalg.norm(axis)
if norm_axis < 1e-8:
# If vectors are parallel or anti-parallel
if np.dot(current_normal, target_normal) > 0:
rot_mat = np.eye(3) # No rotation
else:
rot_mat = np.diag([-1, -1, 1]) # 180-degree rotation
else:
axis = axis / norm_axis
angle = np.arccos(np.dot(current_normal, target_normal))
K = np.array([
[0, -axis[2], axis[1]],
[axis[2], 0, -axis[0]],
[-axis[1], axis[0], 0]
])
rot_mat = np.eye(3) + np.sin(angle) * K + (1 - np.cos(angle)) * K @ K
# expand the dimension of rotation center
center_exp = center.reshape(3, 1)
# perform rotation and translation
positions_rot = rot_mat @ centered_pos + center_exp
return Array3D(
positions = positions_rot,
type = self.type,
spacing = self.spacing,
indices = self.indices,
position_errors = self.position_errors,
gain_errors = self.gain_errors,
phase_errors = self.phase_errors
), rot_mat