Source code for touchstone.parser.models.touchstone_data

"""
Data model for Touchstone parsed data.

This module contains the :class:`TouchstoneData` dataclass, the primary
container for all data parsed from a Touchstone (``.sNp``) file. It holds
frequency arrays, S-parameter matrices, option line configuration,
comments, and metadata.
"""

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple

import numpy as np

from .touchstone_options import TouchstoneOptions

if TYPE_CHECKING:
    from .data_format import DataFormat
    from .frequency_point import FrequencyPoint
    from .frequency_unit import FrequencyUnit
    from .network_parameter import NetworkParameter


[docs] @dataclass(frozen=True) class TouchstoneData: """Structured object representing parsed Touchstone (.sNp) data. This is the main output of :meth:`TouchstoneParser.parse` and :meth:`TouchstoneParser.parse_string`. It is an immutable (frozen) dataclass that provides rich access to the underlying data. Attributes: frequency: 1-D NumPy array of frequency values in Hz. s_parameters: 3-D complex NumPy array of shape ``(n_freq, n_ports, n_ports)``. options: The parsed option line configuration. comments: List of comment strings extracted from the file. filename: The original filename, if known. metadata: Arbitrary metadata dictionary for user extensions. Example: >>> data = TouchstoneParser.parse("filter.s2p") >>> data.n_ports 2 >>> data.n_freq 201 >>> s21 = data.get_s(2, 1) """ frequency: np.ndarray s_parameters: np.ndarray options: TouchstoneOptions = field(default_factory=TouchstoneOptions) comments: List[str] = field(default_factory=list) filename: Optional[str] = None metadata: Dict[str, Any] = field(default_factory=dict) # Backward compatibility properties @property def z0(self) -> float: """Reference impedance of the network in ohms. Returns: float: The reference impedance from the option line. """ return self.options.reference_impedance @property def unit(self) -> str: """Frequency unit string (e.g. ``'Hz'``, ``'GHz'``). Returns: str: The frequency unit value from the option line. """ return self.options.frequency_unit.value @property def parameter(self) -> str: """Network parameter type string (e.g. ``'S'``, ``'Y'``). Returns: str: The parameter type value from the option line. """ return self.options.parameter_type.value @property def format(self) -> str: """Data format string (e.g. ``'MA'``, ``'RI'``). Returns: str: The data format value from the option line. """ return self.options.data_format.value @property def n_ports(self) -> int: """Number of ports in the network. Returns: int: The number of ports, derived from the S-parameter matrix shape. """ return self.s_parameters.shape[1] @property def n_freq(self) -> int: """Number of frequency points in the dataset. Returns: int: The length of the frequency array. """ return len(self.frequency) @property def frequencies(self) -> np.ndarray: """Alias for :attr:`frequency` (compatibility with .NET API). Returns: numpy.ndarray: The frequency array in Hz. """ return self.frequency @property def count(self) -> int: """Alias for :attr:`n_freq` (compatibility with .NET API). Returns: int: The number of frequency points. """ return self.n_freq
[docs] def __post_init__(self): """Validate the data after initialization. Raises: ValueError: If any of the required fields are ``None``, if the number of ports is less than 1, or if the frequency and S-parameter array lengths do not match. """ if self.options is None: raise ValueError("options cannot be None") if self.frequency is None: raise ValueError("frequency array cannot be None") if self.s_parameters is None: raise ValueError("s_parameters array cannot be None") if self.n_ports < 1: raise ValueError(f"Number of ports must be >= 1. Got {self.n_ports}") if self.s_parameters.shape[0] != self.n_freq: raise ValueError( f"Frequency length ({self.n_freq}) must match S-parameter length ({self.s_parameters.shape[0]})" )
[docs] def get_s(self, to_port: int, from_port: int) -> np.ndarray: """Get a specific S-parameter array across all frequencies. Extracts the complex-valued parameter ``S[to_port, from_port]`` for every frequency point. Args: to_port: The target (output) port number, **1-indexed**. from_port: The source (input) port number, **1-indexed**. Returns: numpy.ndarray: 1-D complex array of length :attr:`n_freq`. Raises: IndexError: If port indices are out of range ``[1, n_ports]``. Example: >>> s21 = data.get_s(to_port=2, from_port=1) """ if not (1 <= to_port <= self.n_ports and 1 <= from_port <= self.n_ports): raise IndexError(f"Port indices out of range [1, {self.n_ports}]") return self.s_parameters[:, to_port - 1, from_port - 1]
[docs] def __getitem__(self, index: int) -> "FrequencyPoint": """Access the :class:`FrequencyPoint` at the given index. Args: index: Zero-based index into the frequency array. Returns: FrequencyPoint: The frequency point containing the frequency value and the N × N S-parameter matrix at that index. """ from .frequency_point import FrequencyPoint return FrequencyPoint(self.frequency[index], self.s_parameters[index])
[docs] def get_parameter( self, row: int, col: int ) -> Iterable[Tuple[float, "NetworkParameter"]]: """Iterate over (frequency, NetworkParameter) pairs for a specific matrix entry. Args: row: The row index of the parameter matrix, **1-indexed**. col: The column index of the parameter matrix, **1-indexed**. Yields: tuple: A 2-tuple of ``(frequency_hz, NetworkParameter)`` for each frequency point. Raises: IndexError: If row or col is out of range ``[1, n_ports]``. """ from .network_parameter import NetworkParameter if not (1 <= row <= self.n_ports and 1 <= col <= self.n_ports): raise IndexError(f"Indices out of range [1, {self.n_ports}]") for f, s in zip(self.frequency, self.s_parameters[:, row - 1, col - 1]): yield (f, NetworkParameter(s))
[docs] def where(self, predicate: Callable[["FrequencyPoint"], bool]) -> "TouchstoneData": """Filter the data using a predicate function. Creates a new :class:`TouchstoneData` containing only the frequency points for which the predicate returns ``True``. Args: predicate: A callable that takes a :class:`FrequencyPoint` and returns ``True`` to keep it. Returns: TouchstoneData: A new filtered instance. Example: >>> import numpy as np >>> filtered = data.where(lambda pt: np.abs(pt[0, 0].value) < 0.5) """ keep_indices = [i for i in range(self.n_freq) if predicate(self[i])] return TouchstoneData( frequency=self.frequency[keep_indices], s_parameters=self.s_parameters[keep_indices], options=self.options, comments=self.comments, filename=self.filename, metadata=self.metadata, )
[docs] def to_insertion_loss(self) -> np.ndarray: """Calculate insertion loss from S21. Insertion loss is computed as ``-|S21|`` in dB (positive values indicate loss). Returns: numpy.ndarray: Array of insertion loss values in dB. Raises: ValueError: If the network has fewer than 2 ports. """ from ..utilities.touchstone_data_extensions import to_insertion_loss as _il return _il(self)
[docs] def to_return_loss(self) -> np.ndarray: """Calculate return loss from S11. Return loss is computed as ``-|S11|`` in dB (positive values indicate better matching). Returns: numpy.ndarray: Array of return loss values in dB. """ from ..utilities.touchstone_data_extensions import to_return_loss as _rl return _rl(self)
[docs] def to_vswr(self) -> np.ndarray: """Calculate VSWR (Voltage Standing Wave Ratio) from S11. VSWR is computed as ``(1 + |S11|) / (1 - |S11|)``. Returns: numpy.ndarray: Array of VSWR values (dimensionless, ≥ 1.0). """ from ..utilities.touchstone_data_extensions import to_vswr as _vswr return _vswr(self)
[docs] def in_frequency_range(self, min_hz: float, max_hz: float) -> "TouchstoneData": """Filter the data to a specific frequency range. Args: min_hz: Minimum frequency in Hz (inclusive). max_hz: Maximum frequency in Hz (inclusive). Returns: TouchstoneData: A new instance containing only the points within the specified range. Example: >>> passband = data.in_frequency_range(2.0e9, 3.0e9) """ from ..utilities.touchstone_data_extensions import in_frequency_range as _ifr return _ifr(self, min_hz, max_hz)
[docs] def to_csv_string( self, format: Optional["DataFormat"] = None, unit: Optional["FrequencyUnit"] = None, ) -> str: """Export the data to a CSV formatted string. Args: format: Optional :class:`DataFormat` to override the default. If ``None``, uses the format from :attr:`options`. unit: Optional :class:`FrequencyUnit` to override the default. If ``None``, uses the unit from :attr:`options`. Returns: str: The CSV string representation of the data. """ import io output = io.StringIO() self.to_csv(output, format=format, unit=unit) return output.getvalue()
[docs] def to_csv( self, writer: Any, format: Optional["DataFormat"] = None, unit: Optional["FrequencyUnit"] = None, ) -> None: """Write the data to a CSV file-like object or filepath. Args: writer: A file path (``str``) or a writable file-like object. format: Optional :class:`DataFormat` to override the default. unit: Optional :class:`FrequencyUnit` to override the default. """ # If writer is a string, open it as a file if isinstance(writer, str): with open(writer, "w", newline="", encoding="utf-8") as f: self._write_csv(f, format, unit) else: self._write_csv(writer, format, unit)
def _write_csv( self, file_obj: Any, fmt: Optional["DataFormat"] = None, unit: Optional["FrequencyUnit"] = None, ) -> None: """Internal method to write CSV data to a file object. Args: file_obj: A writable file-like object. fmt: Optional data format override. unit: Optional frequency unit override. """ import csv from ..utilities.frequency_converter import get_multiplier from .data_format import DataFormat from .frequency_unit import FrequencyUnit writer = csv.writer(file_obj) actual_fmt = fmt if fmt is not None else self.options.data_format actual_unit = unit if unit is not None else self.options.frequency_unit headers = [f"Frequency ({actual_unit.value})"] for i in range(1, self.n_ports + 1): for j in range(1, self.n_ports + 1): if actual_fmt == DataFormat.RI: headers.extend([f"S{i}{j}_Re", f"S{i}{j}_Im"]) elif actual_fmt == DataFormat.DB: headers.extend([f"S{i}{j}_dB", f"S{i}{j}_Phase(deg)"]) else: headers.extend([f"S{i}{j}_Mag", f"S{i}{j}_Phase(deg)"]) writer.writerow(headers) for p in range(self.n_freq): f_val = self.frequency[p] / get_multiplier(actual_unit) row = [f_val] for i in range(self.n_ports): for j in range(self.n_ports): s = self.s_parameters[p, i, j] if actual_fmt == DataFormat.RI: row.extend([s.real, s.imag]) elif actual_fmt == DataFormat.DB: mag = np.abs(s) db = 20 * np.log10(mag) if mag > 0 else float("-inf") row.extend([db, np.angle(s, deg=True)]) else: row.extend([np.abs(s), np.angle(s, deg=True)]) writer.writerow(row)
[docs] def __repr__(self) -> str: """Return a developer-friendly string representation. Returns: str: A string showing the number of ports, frequency points, and frequency range. """ file_info = f", file: {self.filename}" if self.filename else "" return ( f"TouchstoneData(n_ports={self.n_ports}, n_freq={self.n_freq}, " f"range=[{self.frequency[0]:.2e}, {self.frequency[-1]:.2e}] Hz{file_info})" )