Skip to content

Antenna

Antenna gain models implementing the AntennaModel protocol.

Parametric Antenna

ParametricAntenna

ParametricAntenna(gain_dbi=0.0, scan_loss_model='none')

Fixed-gain antenna model (e.g., parabolic dish or specified gain).

Returns the same gain in all directions — useful for quick link budgets where the antenna pattern is not the focus.

Parameters:

Name Type Description Default
gain_dbi float

Isotropic antenna gain in dBi (default 0.0).

0.0
scan_loss_model str

Scan-loss model name (default "none").

'none'

Examples:

>>> ant = ParametricAntenna(gain_dbi=36.0)
>>> float(ant.gain_dbi(np.array([30.0]), np.array([0.0]), 12e9)[0])
36.0
Source code in src/opensatcom/antenna/parametric.py
def __init__(self, gain_dbi: float = 0.0, scan_loss_model: str = "none") -> None:
    self._gain_dbi = gain_dbi
    self._scan_loss_model = scan_loss_model

gain_dbi

gain_dbi(theta_deg, phi_deg, f_hz)

Return fixed gain for all directions.

Parameters:

Name Type Description Default
theta_deg ndarray

Elevation angles in degrees.

required
phi_deg ndarray

Azimuth angles in degrees.

required
f_hz float

Carrier frequency in Hz (unused).

required

Returns:

Type Description
ndarray

Constant gain array in dBi, same shape as theta_deg.

Source code in src/opensatcom/antenna/parametric.py
def gain_dbi(
    self, theta_deg: np.ndarray, phi_deg: np.ndarray, f_hz: float
) -> np.ndarray:
    """Return fixed gain for all directions.

    Parameters
    ----------
    theta_deg : numpy.ndarray
        Elevation angles in degrees.
    phi_deg : numpy.ndarray
        Azimuth angles in degrees.
    f_hz : float
        Carrier frequency in Hz (unused).

    Returns
    -------
    numpy.ndarray
        Constant gain array in dBi, same shape as *theta_deg*.
    """
    return np.full_like(theta_deg, self._gain_dbi, dtype=float)

eirp_dbw

eirp_dbw(theta_deg, phi_deg, f_hz, tx_power_w)

Compute EIRP in a given direction.

Parameters:

Name Type Description Default
theta_deg float

Elevation angle in degrees.

required
phi_deg float

Azimuth angle in degrees.

required
f_hz float

Carrier frequency in Hz (unused).

required
tx_power_w float

Transmit power in watts.

required

Returns:

Type Description
float

EIRP in dBW (Ptx_dBW + G_dBi).

Source code in src/opensatcom/antenna/parametric.py
def eirp_dbw(
    self, theta_deg: float, phi_deg: float, f_hz: float, tx_power_w: float
) -> float:
    """Compute EIRP in a given direction.

    Parameters
    ----------
    theta_deg : float
        Elevation angle in degrees.
    phi_deg : float
        Azimuth angle in degrees.
    f_hz : float
        Carrier frequency in Hz (unused).
    tx_power_w : float
        Transmit power in watts.

    Returns
    -------
    float
        EIRP in dBW (``Ptx_dBW + G_dBi``).
    """
    return w_to_dbw(tx_power_w) + self._gain_dbi

Cosine Rolloff Antenna

CosineRolloffAntenna

CosineRolloffAntenna(peak_gain_dbi, theta_3db_deg, sidelobe_floor_dbi=-20.0, boresight_az_deg=0.0, boresight_el_deg=0.0)

Simple analytic antenna model with cosine-squared rolloff.

Gain pattern: gain(theta_off) = peak_gain_dbi - 12*(theta_off / theta_3db)^2 for theta_off < theta_3db * 2.6, otherwise sidelobe_floor_dbi.

This produces a realistic off-axis pattern suitable for multi-beam interference analysis without requiring the full PAM library.

Parameters:

Name Type Description Default
peak_gain_dbi float

Peak (boresight) antenna gain in dBi.

required
theta_3db_deg float

Half-power (3 dB) beamwidth in degrees.

required
sidelobe_floor_dbi float

Minimum gain floor representing the sidelobe level, in dBi. Default is -20.0.

-20.0
boresight_az_deg float

Azimuth angle of the boresight direction in degrees. Default is 0.0.

0.0
boresight_el_deg float

Elevation angle of the boresight direction in degrees. Default is 0.0.

0.0

Examples:

>>> ant = CosineRolloffAntenna(peak_gain_dbi=36.0, theta_3db_deg=1.5)
>>> ant.gain_toward_dbi(az_deg=0.0, el_deg=0.0, f_hz=12e9)
36.0
Source code in src/opensatcom/antenna/cosine.py
def __init__(
    self,
    peak_gain_dbi: float,
    theta_3db_deg: float,
    sidelobe_floor_dbi: float = -20.0,
    boresight_az_deg: float = 0.0,
    boresight_el_deg: float = 0.0,
) -> None:
    self._peak_gain_dbi = peak_gain_dbi
    self._theta_3db_deg = theta_3db_deg
    self._sidelobe_floor_dbi = sidelobe_floor_dbi
    self._boresight_az_deg = boresight_az_deg
    self._boresight_el_deg = boresight_el_deg

peak_gain_dbi property

peak_gain_dbi

Peak (boresight) antenna gain.

Returns:

Type Description
float

Peak gain in dBi.

theta_3db_deg property

theta_3db_deg

Half-power (3 dB) beamwidth.

Returns:

Type Description
float

Beamwidth in degrees.

sidelobe_floor_dbi property

sidelobe_floor_dbi

Minimum gain floor representing the sidelobe level.

Returns:

Type Description
float

Sidelobe floor in dBi.

boresight_az_deg property

boresight_az_deg

Azimuth angle of the boresight direction.

Returns:

Type Description
float

Boresight azimuth in degrees.

boresight_el_deg property

boresight_el_deg

Elevation angle of the boresight direction.

Returns:

Type Description
float

Boresight elevation in degrees.

gain_dbi

gain_dbi(theta_deg, phi_deg, f_hz)

Return gain accounting for off-axis rolloff.

Parameters:

Name Type Description Default
theta_deg ndarray

Azimuth angles in degrees.

required
phi_deg ndarray

Elevation angles in degrees.

required
f_hz float

Frequency in Hz. Unused in this analytic model but accepted for interface compatibility with AntennaModel.

required

Returns:

Type Description
ndarray

Gain values in dBi, clamped to the sidelobe floor.

Source code in src/opensatcom/antenna/cosine.py
def gain_dbi(
    self, theta_deg: np.ndarray, phi_deg: np.ndarray, f_hz: float
) -> np.ndarray:
    """Return gain accounting for off-axis rolloff.

    Parameters
    ----------
    theta_deg : numpy.ndarray
        Azimuth angles in degrees.
    phi_deg : numpy.ndarray
        Elevation angles in degrees.
    f_hz : float
        Frequency in Hz. Unused in this analytic model but accepted
        for interface compatibility with ``AntennaModel``.

    Returns
    -------
    numpy.ndarray
        Gain values in dBi, clamped to the sidelobe floor.
    """
    off_axis = self._off_axis_angle_deg(theta_deg, phi_deg)
    # Main lobe: parabolic rolloff  gain = peak - 12*(theta/theta_3db)^2
    gain = self._peak_gain_dbi - 12.0 * (off_axis / self._theta_3db_deg) ** 2
    # Clamp to sidelobe floor (applies both in main lobe tail and beyond)
    gain = np.maximum(gain, self._sidelobe_floor_dbi)
    return gain

gain_toward_dbi

gain_toward_dbi(az_deg, el_deg, f_hz)

Scalar convenience: gain in a specific direction.

Parameters:

Name Type Description Default
az_deg float

Azimuth angle in degrees toward which to evaluate the gain.

required
el_deg float

Elevation angle in degrees toward which to evaluate the gain.

required
f_hz float

Frequency in Hz. Unused in this analytic model but accepted for interface compatibility with AntennaModel.

required

Returns:

Type Description
float

Antenna gain in the specified direction, in dBi.

Source code in src/opensatcom/antenna/cosine.py
def gain_toward_dbi(self, az_deg: float, el_deg: float, f_hz: float) -> float:
    """Scalar convenience: gain in a specific direction.

    Parameters
    ----------
    az_deg : float
        Azimuth angle in degrees toward which to evaluate the gain.
    el_deg : float
        Elevation angle in degrees toward which to evaluate the gain.
    f_hz : float
        Frequency in Hz. Unused in this analytic model but accepted
        for interface compatibility with ``AntennaModel``.

    Returns
    -------
    float
        Antenna gain in the specified direction, in dBi.
    """
    return float(
        self.gain_dbi(np.array([az_deg]), np.array([el_deg]), f_hz)[0]
    )

eirp_dbw

eirp_dbw(theta_deg, phi_deg, f_hz, tx_power_w)

Compute EIRP in a given direction.

EIRP is calculated as Ptx(dBW) + G(dBi) where the gain is evaluated at the specified azimuth/elevation angles.

Parameters:

Name Type Description Default
theta_deg float

Azimuth angle in degrees toward which to evaluate the EIRP.

required
phi_deg float

Elevation angle in degrees toward which to evaluate the EIRP.

required
f_hz float

Frequency in Hz. Passed through to :meth:gain_toward_dbi.

required
tx_power_w float

Transmit power in Watts.

required

Returns:

Type Description
float

Effective isotropic radiated power in dBW.

Source code in src/opensatcom/antenna/cosine.py
def eirp_dbw(
    self, theta_deg: float, phi_deg: float, f_hz: float, tx_power_w: float
) -> float:
    """Compute EIRP in a given direction.

    EIRP is calculated as ``Ptx(dBW) + G(dBi)`` where the gain is
    evaluated at the specified azimuth/elevation angles.

    Parameters
    ----------
    theta_deg : float
        Azimuth angle in degrees toward which to evaluate the EIRP.
    phi_deg : float
        Elevation angle in degrees toward which to evaluate the EIRP.
    f_hz : float
        Frequency in Hz. Passed through to :meth:`gain_toward_dbi`.
    tx_power_w : float
        Transmit power in Watts.

    Returns
    -------
    float
        Effective isotropic radiated power in dBW.
    """
    g = self.gain_toward_dbi(theta_deg, phi_deg, f_hz)
    return w_to_dbw(tx_power_w) + g

PAM Array Antenna

PamArrayAntenna

PamArrayAntenna(nx=1, ny=1, dx_lambda=0.5, dy_lambda=0.5, taper=None, steering=None, impairments=None)

Phased array antenna wrapping PAM, with analytic fallback.

Models a planar phased-array antenna as an Nx-by-Ny rectangular lattice of isotropic elements. When the optional PAM package is installed, the full pattern synthesis engine is used; otherwise an analytic peak-gain approximation is provided via the standard aperture formula D = 4 * pi * Nx * dx * Ny * dy (element spacings in wavelengths).

Parameters:

Name Type Description Default
nx int

Number of elements along the x-axis, by default 1.

1
ny int

Number of elements along the y-axis, by default 1.

1
dx_lambda float

Element spacing along x in wavelengths, by default 0.5.

0.5
dy_lambda float

Element spacing along y in wavelengths, by default 0.5.

0.5
taper tuple of (str, float), str, or None

Amplitude taper specification. Can be a taper name string (e.g. "uniform") or a (name, sll_db) tuple for parameterised tapers (e.g. ("taylor", -25)). None applies no taper (uniform illumination), by default None.

None
steering Any or None

Beam-steering specification passed through to PAM. Ignored when PAM is not available, by default None.

None
impairments Any or None

Element-level impairment model (amplitude/phase errors) passed through to PAM. Ignored when PAM is not available, by default None.

None
Source code in src/opensatcom/antenna/pam.py
def __init__(
    self,
    nx: int = 1,
    ny: int = 1,
    dx_lambda: float = 0.5,
    dy_lambda: float = 0.5,
    taper: tuple[str, float] | str | None = None,
    steering: Any | None = None,
    impairments: Any | None = None,
) -> None:
    self.nx = nx
    self.ny = ny
    self.dx_lambda = dx_lambda
    self.dy_lambda = dy_lambda
    self.taper = taper
    self.steering = steering
    self.impairments = impairments

    self._pam_available = False
    try:
        import pam  # noqa: F401

        self._pam_available = True
    except ImportError:
        warnings.warn(
            "PAM package not installed — PamArrayAntenna will return peak gain "
            "at all angles (no pattern shaping). Install PAM for accurate patterns.",
            stacklevel=2,
        )

    # Compute analytic peak gain: D = 4*pi*Nx*dx*Ny*dy (in wavelengths^2)
    aperture_lambda_sq = nx * dx_lambda * ny * dy_lambda
    self._peak_gain_lin = 4.0 * math.pi * aperture_lambda_sq
    self._peak_gain_dbi = lin_to_db10(self._peak_gain_lin)

gain_dbi

gain_dbi(theta_deg, phi_deg, f_hz)

Return peak gain (analytic fallback).

Computes the antenna gain at the requested angular coordinates. In the current analytic-fallback mode the returned array is filled with the peak directivity for every direction; full pattern shaping is available when PAM is installed.

Parameters:

Name Type Description Default
theta_deg ndarray

Elevation angles in degrees. The shape of the output matches this array.

required
phi_deg ndarray

Azimuth angles in degrees. Must be broadcastable with theta_deg.

required
f_hz float

Operating frequency in hertz.

required

Returns:

Type Description
ndarray

Gain values in dBi, with the same shape as theta_deg.

Source code in src/opensatcom/antenna/pam.py
def gain_dbi(
    self, theta_deg: np.ndarray, phi_deg: np.ndarray, f_hz: float
) -> np.ndarray:
    """Return peak gain (analytic fallback).

    Computes the antenna gain at the requested angular coordinates.
    In the current analytic-fallback mode the returned array is filled
    with the peak directivity for every direction; full pattern shaping
    is available when PAM is installed.

    Parameters
    ----------
    theta_deg : numpy.ndarray
        Elevation angles in degrees.  The shape of the output matches
        this array.
    phi_deg : numpy.ndarray
        Azimuth angles in degrees.  Must be broadcastable with
        ``theta_deg``.
    f_hz : float
        Operating frequency in hertz.

    Returns
    -------
    numpy.ndarray
        Gain values in dBi, with the same shape as ``theta_deg``.
    """
    return np.full_like(theta_deg, self._peak_gain_dbi, dtype=float)

eirp_dbw

eirp_dbw(theta_deg, phi_deg, f_hz, tx_power_w)

EIRP = Ptx(dBW) + G(dBi).

Computes the Effective Isotropic Radiated Power by adding the transmit power (converted to dBW) and the antenna peak gain (dBi).

Parameters:

Name Type Description Default
theta_deg float

Elevation angle in degrees toward the target.

required
phi_deg float

Azimuth angle in degrees toward the target.

required
f_hz float

Operating frequency in hertz.

required
tx_power_w float

Transmitter output power in watts (linear).

required

Returns:

Type Description
float

EIRP in dBW.

Source code in src/opensatcom/antenna/pam.py
def eirp_dbw(
    self, theta_deg: float, phi_deg: float, f_hz: float, tx_power_w: float
) -> float:
    """EIRP = Ptx(dBW) + G(dBi).

    Computes the Effective Isotropic Radiated Power by adding the
    transmit power (converted to dBW) and the antenna peak gain (dBi).

    Parameters
    ----------
    theta_deg : float
        Elevation angle in degrees toward the target.
    phi_deg : float
        Azimuth angle in degrees toward the target.
    f_hz : float
        Operating frequency in hertz.
    tx_power_w : float
        Transmitter output power in watts (linear).

    Returns
    -------
    float
        EIRP in dBW.
    """
    return w_to_dbw(tx_power_w) + self._peak_gain_dbi

Coupling-Aware Antenna

CouplingAwareAntenna

CouplingAwareAntenna(coupling_data, steering_az_deg=0.0, steering_el_deg=0.0)

Antenna model using EdgeFEM coupling data for direction-dependent gain.

Computes array gain using embedded element patterns corrected for mutual coupling. The gain at each direction accounts for: 1. Active element patterns (from EdgeFEM simulation) 2. Coupling correction via S-parameter matrix 3. Array factor with element positions and steering weights

Parameters:

Name Type Description Default
coupling_data CouplingData

Coupling data loaded from an EdgeFEM artifact.

required
steering_az_deg float

Beam steering azimuth in degrees (default 0.0).

0.0
steering_el_deg float

Beam steering elevation in degrees (default 0.0).

0.0
Source code in src/opensatcom/antenna/coupling.py
def __init__(
    self,
    coupling_data: CouplingData,
    steering_az_deg: float = 0.0,
    steering_el_deg: float = 0.0,
) -> None:
    self._data = coupling_data
    self._steering_az = steering_az_deg
    self._steering_el = steering_el_deg

    # Precompute coupling correction matrix: (I + S)^-1
    n = coupling_data.n_elements
    identity = np.eye(n, dtype=complex)
    self._coupling_correction = np.linalg.inv(
        identity + coupling_data.coupling_matrix
    )

    # Precompute steering weights
    self._weights = self._compute_steering_weights()

gain_dbi

gain_dbi(theta_deg, phi_deg, f_hz)

Return gain in dBi accounting for coupling effects.

Parameters:

Name Type Description Default
theta_deg ndarray

Azimuth angles in degrees.

required
phi_deg ndarray

Elevation angles in degrees.

required
f_hz float

Carrier frequency in Hz (for reference; pattern data at nearest freq).

required

Returns:

Type Description
ndarray

Gain values in dBi, same shape as theta_deg.

Source code in src/opensatcom/antenna/coupling.py
def gain_dbi(
    self, theta_deg: np.ndarray, phi_deg: np.ndarray, f_hz: float
) -> np.ndarray:
    """Return gain in dBi accounting for coupling effects.

    Parameters
    ----------
    theta_deg : numpy.ndarray
        Azimuth angles in degrees.
    phi_deg : numpy.ndarray
        Elevation angles in degrees.
    f_hz : float
        Carrier frequency in Hz (for reference; pattern data at nearest freq).

    Returns
    -------
    numpy.ndarray
        Gain values in dBi, same shape as *theta_deg*.
    """
    gain_lin = self._evaluate_array_gain(theta_deg, phi_deg)
    # Normalize to directivity: 4*pi * gain / (sum over sphere)
    # For simplicity, use the raw array gain with element patterns
    # that are already calibrated in dBi-equivalent units
    safe_gain = np.maximum(gain_lin, 1e-20)
    return np.array([lin_to_db10(g) for g in safe_gain])

eirp_dbw

eirp_dbw(theta_deg, phi_deg, f_hz, tx_power_w)

Compute EIRP in a given direction.

Parameters:

Name Type Description Default
theta_deg float

Azimuth angle in degrees.

required
phi_deg float

Elevation angle in degrees.

required
f_hz float

Carrier frequency in Hz.

required
tx_power_w float

Transmit power in watts.

required

Returns:

Type Description
float

EIRP in dBW.

Source code in src/opensatcom/antenna/coupling.py
def eirp_dbw(
    self, theta_deg: float, phi_deg: float, f_hz: float, tx_power_w: float
) -> float:
    """Compute EIRP in a given direction.

    Parameters
    ----------
    theta_deg : float
        Azimuth angle in degrees.
    phi_deg : float
        Elevation angle in degrees.
    f_hz : float
        Carrier frequency in Hz.
    tx_power_w : float
        Transmit power in watts.

    Returns
    -------
    float
        EIRP in dBW.
    """
    g = self.gain_dbi(np.array([theta_deg]), np.array([phi_deg]), f_hz)
    return w_to_dbw(tx_power_w) + float(g[0])

from_npz classmethod

from_npz(artifact_path, steering_az_deg=0.0, steering_el_deg=0.0)

Load coupling data from .npz and construct antenna.

Parameters:

Name Type Description Default
artifact_path str or Path

Path to the .npz file containing EdgeFEM coupling data.

required
steering_az_deg float

Beam steering azimuth in degrees (default 0.0).

0.0
steering_el_deg float

Beam steering elevation in degrees (default 0.0).

0.0

Returns:

Type Description
CouplingAwareAntenna

Constructed antenna with loaded coupling data.

Source code in src/opensatcom/antenna/coupling.py
@classmethod
def from_npz(
    cls,
    artifact_path: str | Path,
    steering_az_deg: float = 0.0,
    steering_el_deg: float = 0.0,
) -> CouplingAwareAntenna:
    """Load coupling data from .npz and construct antenna.

    Parameters
    ----------
    artifact_path : str or Path
        Path to the ``.npz`` file containing EdgeFEM coupling data.
    steering_az_deg : float
        Beam steering azimuth in degrees (default 0.0).
    steering_el_deg : float
        Beam steering elevation in degrees (default 0.0).

    Returns
    -------
    CouplingAwareAntenna
        Constructed antenna with loaded coupling data.
    """
    data = load_npz_artifact(artifact_path)
    return cls(data, steering_az_deg, steering_el_deg)

EdgeFEM Loader

edgefem_loader

EdgeFEM artifact loading — coupling matrices and element patterns.

CouplingData dataclass

CouplingData(coupling_matrix, element_patterns, theta_grid_deg, phi_grid_deg, freq_hz, array_positions_m, metadata=dict())

Parsed coupling and element pattern data from EdgeFEM artifacts.

Attributes:

Name Type Description
coupling_matrix complex S-parameter matrix, shape (N_elem, N_elem)
element_patterns complex gain per element, shape (N_elem, N_theta, N_phi)
theta_grid_deg elevation angle grid, shape (N_theta,)
phi_grid_deg azimuth angle grid, shape (N_phi,)
freq_hz frequency (scalar or array of frequencies)
array_positions_m element positions, shape (N_elem, 2) or (N_elem, 3)
metadata additional metadata from the artifact

load_npz_artifact

load_npz_artifact(path)

Load an EdgeFEM artifact from a .npz file.

Expected arrays in the .npz: - coupling_matrix: (N_elem, N_elem) complex - element_patterns: (N_elem, N_theta, N_phi) complex - theta_grid_deg: (N_theta,) - phi_grid_deg: (N_phi,) - freq_hz: scalar or (N_freq,) - array_positions_m: (N_elem, 2) or (N_elem, 3)

Optional: - metadata_keys, metadata_values: parallel arrays for metadata

Source code in src/opensatcom/antenna/edgefem_loader.py
def load_npz_artifact(path: str | Path) -> CouplingData:
    """Load an EdgeFEM artifact from a .npz file.

    Expected arrays in the .npz:
    - coupling_matrix: (N_elem, N_elem) complex
    - element_patterns: (N_elem, N_theta, N_phi) complex
    - theta_grid_deg: (N_theta,)
    - phi_grid_deg: (N_phi,)
    - freq_hz: scalar or (N_freq,)
    - array_positions_m: (N_elem, 2) or (N_elem, 3)

    Optional:
    - metadata_keys, metadata_values: parallel arrays for metadata
    """
    path = Path(path)
    if not path.exists():
        raise FileNotFoundError(f"EdgeFEM artifact not found: {path}")

    data = np.load(path, allow_pickle=True)

    required = ["coupling_matrix", "element_patterns", "theta_grid_deg",
                 "phi_grid_deg", "freq_hz", "array_positions_m"]
    for key in required:
        if key not in data:
            raise ValueError(f"Missing required array '{key}' in {path}")

    freq = data["freq_hz"]
    freq_val: float | np.ndarray = float(freq) if freq.ndim == 0 else freq

    metadata: dict[str, Any] = {}
    if "metadata_keys" in data and "metadata_values" in data:
        keys = data["metadata_keys"]
        vals = data["metadata_values"]
        for k, v in zip(keys, vals):
            metadata[str(k)] = v

    return CouplingData(
        coupling_matrix=data["coupling_matrix"],
        element_patterns=data["element_patterns"],
        theta_grid_deg=data["theta_grid_deg"],
        phi_grid_deg=data["phi_grid_deg"],
        freq_hz=freq_val,
        array_positions_m=data["array_positions_m"],
        metadata=metadata,
    )

load_touchstone_coupling

load_touchstone_coupling(s_param_path, pattern_path=None)

Load S-parameter coupling matrix from a Touchstone .sNp file.

Returns the coupling matrix at the first frequency point. Only the S-parameter matrix is extracted; element patterns must be provided separately (e.g., via .npz).

Parameters:

Name Type Description Default
s_param_path path to .sNp Touchstone file
required
pattern_path unused, reserved for future pattern file loading
None
Source code in src/opensatcom/antenna/edgefem_loader.py
def load_touchstone_coupling(
    s_param_path: str | Path,
    pattern_path: str | Path | None = None,
) -> np.ndarray:
    """Load S-parameter coupling matrix from a Touchstone .sNp file.

    Returns the coupling matrix at the first frequency point.
    Only the S-parameter matrix is extracted; element patterns must be
    provided separately (e.g., via .npz).

    Parameters
    ----------
    s_param_path : path to .sNp Touchstone file
    pattern_path : unused, reserved for future pattern file loading
    """
    path = Path(s_param_path)
    if not path.exists():
        raise FileNotFoundError(f"Touchstone file not found: {path}")

    # Determine number of ports from extension
    suffix = path.suffix.lower()
    if not suffix.startswith(".s") or not suffix.endswith("p"):
        raise ValueError(f"Not a Touchstone file: {path}")
    n_ports = int(suffix[2:-1])

    freq_list: list[float] = []
    s_data_rows: list[list[float]] = []
    data_format = "ma"  # default: magnitude/angle

    with open(path) as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("!"):
                continue
            if line.startswith("#"):
                # Option line: # GHz S MA R 50
                parts = line[1:].split()
                for p in parts:
                    if p.upper() in ("MA", "DB", "RI"):
                        data_format = p.upper()
                continue

            # Data line
            values = [float(x) for x in line.split()]
            if len(values) > 2 * n_ports * n_ports:
                # Frequency + S-data on one line
                freq_list.append(values[0])
                s_data_rows.append(values[1:])
            elif freq_list and len(s_data_rows[-1]) < 2 * n_ports * n_ports:
                # Continuation line
                s_data_rows[-1].extend(values)
            else:
                freq_list.append(values[0])
                s_data_rows.append(values[1:])

    if not freq_list:
        raise ValueError(f"No data found in Touchstone file: {path}")

    # Parse first frequency point into complex S-matrix
    row = s_data_rows[0]
    s_matrix = np.zeros((n_ports, n_ports), dtype=complex)

    for i in range(n_ports):
        for j in range(n_ports):
            idx = (i * n_ports + j) * 2
            if idx + 1 >= len(row):
                break
            v1, v2 = row[idx], row[idx + 1]
            if data_format == "MA":
                s_matrix[i, j] = v1 * np.exp(1j * np.radians(v2))
            elif data_format == "DB":
                mag = 10 ** (v1 / 20.0)
                s_matrix[i, j] = mag * np.exp(1j * np.radians(v2))
            elif data_format == "RI":
                s_matrix[i, j] = complex(v1, v2)

    return s_matrix