Source code for touchstone.parser.parsing.touchstone_parser

"""
Core Touchstone file parser.

This module contains the :class:`TouchstoneParser` class, which provides
static methods for reading Touchstone (``.sNp``) files from disk, strings,
or streams and converting them into :class:`~touchstone.parser.models.touchstone_data.TouchstoneData` objects.
"""

import os
import re
from typing import Iterable, List, Optional, TextIO

import numpy as np

from ..models.data_format import DataFormat
from ..models.touchstone_data import TouchstoneData
from ..utilities.frequency_converter import normalize_frequency
from ..utilities.network_parameter_extensions import (
    db_to_complex,
    ma_to_complex,
    ri_to_complex,
)
from .data_line_tokenizer import DataLineTokenizer
from .option_line_parser import OptionLineParser
from .touchstone_parser_exception import TouchstoneParserException


[docs] class TouchstoneParser: """Parser for reading Touchstone (.sNp) files into structured data. All methods are static — there is no need to instantiate this class. Example: >>> data = TouchstoneParser.parse("filter.s2p") >>> data.n_ports 2 """ @staticmethod def _infer_n_ports(total_numbers: int) -> int: """Infer the number of ports from the total count of numeric values. Each frequency point contributes ``1 + 2*N²`` numbers (one frequency value plus N² complex pairs). This method tries N from 1 to 8 and returns the first that evenly divides ``total_numbers``. Args: total_numbers: The total count of parsed numeric values. Returns: int: The inferred number of ports, defaulting to 2 if no match is found. """ for n in range(1, 9): nums_per_freq = 1 + (n**2 * 2) if total_numbers > 0 and total_numbers % nums_per_freq == 0: return n return 2 @staticmethod def _parse_lines( lines: Iterable[str], n_ports: int = 0, filename: Optional[str] = None ) -> TouchstoneData: """Parse Touchstone data from an iterable of text lines. This is the core parsing method used by all public entry points. It processes comments, the option line, and numerical data lines to produce a fully populated :class:`TouchstoneData` instance. Args: lines: An iterable of string lines (e.g., from a file or ``str.splitlines()``). n_ports: Number of ports. If ``0``, the port count is auto-detected from the data. filename: Optional filename for metadata. Returns: TouchstoneData: The parsed data. Raises: TouchstoneParserException: If multiple option lines are found or numeric values cannot be parsed. """ comments: List[str] = [] options = None lines_list = list(lines) if not isinstance(lines, list) else lines for line_idx, line in enumerate(lines_list, start=1): trimmed = line.strip() if not trimmed: continue if trimmed.startswith("!"): # Only collect comments that appear before data or options? # The original just collected everything. We'll keep it. comments.append(trimmed[1:].strip()) continue clean_line = line.split("!")[0].strip() if clean_line.startswith("#"): if options is not None: raise TouchstoneParserException( "Multiple option lines found", line_idx ) options = OptionLineParser.parse(clean_line) if options is None: options = OptionLineParser.parse("# GHZ S MA R 50") numbers = list(DataLineTokenizer.get_numbers(lines_list)) if n_ports <= 0: n_ports = TouchstoneParser._infer_n_ports(len(numbers)) nums_per_freq = 1 + (n_ports**2 * 2) if len(numbers) % nums_per_freq != 0: n_points = len(numbers) // nums_per_freq numbers = numbers[: n_points * nums_per_freq] else: n_points = len(numbers) // nums_per_freq if len(numbers) == 0: # Handle empty data return TouchstoneData( frequency=np.array([]), s_parameters=np.zeros((0, n_ports, n_ports), dtype=complex), options=options, comments=comments, filename=filename, ) data = np.array(numbers).reshape(n_points, nums_per_freq) freqs = np.array( [normalize_frequency(f, options.frequency_unit.value) for f in data[:, 0]] ) raw_s = data[:, 1:] s_params = np.zeros((n_points, n_ports, n_ports), dtype=complex) def to_complex(v1, v2): if options.data_format == DataFormat.RI: return ri_to_complex(v1, v2) elif options.data_format == DataFormat.DB: return db_to_complex(v1, v2) else: return ma_to_complex(v1, v2) for p in range(n_points): row = raw_s[p] if n_ports == 1: s_params[p, 0, 0] = to_complex(row[0], row[1]) elif n_ports == 2: s_params[p, 0, 0] = to_complex(row[0], row[1]) s_params[p, 1, 0] = to_complex(row[2], row[3]) s_params[p, 0, 1] = to_complex(row[4], row[5]) s_params[p, 1, 1] = to_complex(row[6], row[7]) else: idx = 0 for i in range(n_ports): for j in range(n_ports): s_params[p, i, j] = to_complex(row[idx], row[idx + 1]) idx += 2 return TouchstoneData( frequency=freqs, s_parameters=s_params, options=options, comments=comments, filename=filename, )
[docs] @staticmethod def detect_port_count(filename: str) -> int: """Detect the number of ports from the filename extension. Parses the ``.sNp`` extension to extract the port count ``N``. Args: filename: The name of the file (e.g. ``'filter.s2p'``). Returns: int: The detected number of ports. Raises: TouchstoneParserException: If the extension does not match the ``.sNp`` pattern. ValueError: If the filename is null or empty. Example: >>> TouchstoneParser.detect_port_count("amp.s4p") 4 """ if not filename: raise ValueError("filename cannot be null or empty") base = os.path.basename(filename) ext_match = re.search(r"\.s(\d+)p$", base.lower()) if not ext_match: raise TouchstoneParserException( f"Could not determine port count from extension of {filename}" ) return int(ext_match.group(1))
[docs] @staticmethod def parse(filepath: str) -> TouchstoneData: """Parse a Touchstone file from disk. The port count is auto-detected from the file extension. Args: filepath: Absolute or relative path to the ``.sNp`` file. Returns: TouchstoneData: The fully parsed data. Raises: TouchstoneParserException: If the file extension is invalid. ValueError: If filepath is empty. FileNotFoundError: If the file does not exist. Example: >>> data = TouchstoneParser.parse("filter.s2p") """ if not filepath: raise ValueError("filepath cannot be null or empty") filename = os.path.basename(filepath) n_ports = TouchstoneParser.detect_port_count(filename) with open(filepath, "r", encoding="utf-8") as f: return TouchstoneParser._parse_lines(f, n_ports=n_ports, filename=filename)
[docs] @staticmethod def parse_string( content: str, n_ports: int = 0, filename: Optional[str] = None ) -> TouchstoneData: """Parse Touchstone data from a string. Args: content: The raw Touchstone file content as a string. n_ports: Number of ports. Pass ``0`` to auto-detect from the data (default). filename: Optional filename for metadata purposes. Returns: TouchstoneData: The parsed data. Example: >>> data = TouchstoneParser.parse_string(raw_text, n_ports=2) """ lines = content.splitlines() return TouchstoneParser._parse_lines(lines, n_ports=n_ports, filename=filename)
[docs] @staticmethod def parse_stream(stream: TextIO, filename: Optional[str] = None) -> TouchstoneData: """Parse Touchstone data from a text stream. If a filename is provided, the port count is detected from its extension. Otherwise, it is auto-detected from the data. Args: stream: A readable text stream (e.g., ``io.StringIO``). filename: Optional filename for port detection and metadata. Returns: TouchstoneData: The parsed data. Example: >>> import io >>> stream = io.StringIO(raw_text) >>> data = TouchstoneParser.parse_stream(stream, filename="test.s2p") """ n_ports = 0 if filename: try: n_ports = TouchstoneParser.detect_port_count(filename) except TouchstoneParserException: pass return TouchstoneParser._parse_lines(stream, n_ports=n_ports, filename=filename)
[docs] @staticmethod async def parse_async(filepath: str) -> TouchstoneData: """Asynchronously parse a Touchstone file from disk. Delegates to :meth:`parse` in a background thread using :func:`asyncio.to_thread`. Args: filepath: Path to the ``.sNp`` file. Returns: TouchstoneData: The parsed data. Example: >>> data = await TouchstoneParser.parse_async("filter.s2p") """ import asyncio return await asyncio.to_thread(TouchstoneParser.parse, filepath)