"""
.. module:: skrf.io.touchstone
========================================
touchstone (:mod:`skrf.io.touchstone`)
========================================
Touchstone class and utilities
.. autosummary::
:toctree: generated/
Touchstone
Functions related to reading/writing touchstones.
-------------------------------------------------
.. autosummary::
:toctree: generated/
hfss_touchstone_2_gamma_z0
hfss_touchstone_2_media
hfss_touchstone_2_network
read_zipped_touchstones
"""
from __future__ import annotations
import os
import re
import typing
import warnings
import zipfile
from dataclasses import dataclass, field
from typing import Callable
import numpy as npy
from ..constants import FREQ_UNITS, S_DEF_HFSS_DEFAULT
from ..media import DefinedGammaZ0
from ..network import Network
from ..util import get_fid
def remove_prefix(text: str, prefix: str) -> str:
if text.startswith(prefix):
return text[len(prefix) :]
return text
@dataclass
class ParserState:
"""Class to hold dynamic variables while parsing the touchstone file.
"""
rank: int | None = None
option_line_parsed: bool = False
hfss_gamma: list[list[float]] = field(default_factory=list)
hfss_impedance: list[list[float]] = field(default_factory=list)
comments: list[str] = field(default_factory=list)
comments_after_option_line: list[str] = field(default_factory=list)
matrix_format: str = "full"
parse_network: bool = True
_parse_noise: bool = False
f: list[float] = field(default_factory=list)
f_noise: list[float] = field(default_factory=list)
s: list[float] = field(default_factory=list)
noise: list[float] = field(default_factory=list)
two_port_order_legacy: bool = True
number_noise_freq: int = 0
port_names: dict[int, str] = field(default_factory=dict)
ansys_data_type: str | None = None
mixed_mode_order: list[str] | None = None
frequency_unit: str = "ghz"
parameter: str = "s"
format: str = "ma"
resistance: complex = complex(50)
@property
def n_ansys_impedance_values(self) -> int:
"""Returns the number of port impedances returned by Ansys HFSS.
Currently this function returns rank * 2.
Returns:
int: number of impedance values.
"""
# See https://github.com/scikit-rf/scikit-rf/issues/354 for details.
#if self.ansys_data_type == "terminal":
# return self.rank**2 * 2
return self.rank * 2
@property
def numbers_per_line(self) -> int:
"""Returns data points per frequency point.
Returns:
int: Number of data points per frequency point.
"""
if self.matrix_format == "full":
return self.rank**2 * 2
return self.rank**2 * self.rank
@property
def parse_noise(self) -> bool:
"""Returns true if the parser expects noise data.
Returns:
bool: True, if noise data is expected.
"""
return self._parse_noise
@parse_noise.setter
def parse_noise(self, x: bool) -> None:
self.parse_network = False
self._parse_noise = x
@property
def frequency_mult(self) -> float:
_units = {k.lower(): v for k,v in FREQ_UNITS.items()}
return _units[self.frequency_unit]
def parse_port(self, line: str):
"""Regex parser for port names.
Args:
line (str): Touchstone line.
"""
m = re.match(r"! Port\[(\d+)\]\s*=\s*(.*)$", line)
if m:
self.port_names[int(m.group(1)) - 1] = m.group(2)
def append_comment(self, line: str) -> None:
"""Append comment, and append appropriate comment list.
Args:
line (str): Line to parse
"""
if self.option_line_parsed:
self.comments_after_option_line.append(line)
else:
self.comments.append(line)
def parse_option_line(self, line: str) -> None:
"""Parse the option line starting with #
Args:
line (str): Line to parse
Raises:
ValueError: If option line contains invalid options.
"""
if self.option_line_parsed:
return
toks = line.lower()[1:].strip().split()
# fill the option line with the missing defaults
toks.extend(["ghz", "s", "ma", "r", "50"][len(toks) :])
self.frequency_unit = toks[0]
self.parameter = toks[1]
self.format = toks[2]
self.resistance = complex(toks[4])
err_msg = ""
if self.frequency_unit not in ["hz", "khz", "mhz", "ghz"]:
err_msg = f"ERROR: illegal frequency_unit {self.frequency_unit}\n"
if self.parameter not in "syzgh":
err_msg = f"ERROR: illegal parameter value {self.parameter}\n"
if self.format not in ["ma", "db", "ri"]:
err_msg = f"ERROR: illegal format value {self.format}\n"
if err_msg:
raise ValueError(err_msg)
self.option_line_parsed = True
[docs]
class Touchstone:
"""
Class to read touchstone s-parameter files.
The reference for writing this class is the draft of the
Touchstone(R) File Format Specification Rev 2.0 [#]_ and
Touchstone(R) File Format Specification Version 2.0 [#]_
References
----------
.. [#] https://ibis.org/interconnect_wip/touchstone_spec2_draft.pdf
.. [#] https://ibis.org/touchstone_ver2.0/touchstone_ver2_0.pdf
"""
[docs]
def __init__(self, file: str | typing.TextIO, encoding: str | None = None):
"""
constructor
Parameters
----------
file : str or file-object
touchstone file to load
encoding : str, optional
define the file encoding to use. Default value is None,
meaning the encoding is guessed (ANSI, UTF-8 or Latin-1).
Examples
--------
From filename
>>> t = rf.Touchstone('network.s2p')
File encoding can be specified to help parsing the special characters:
>>> t = rf.Touchstone('network.s2p', encoding='ISO-8859-1')
From file-object
>>> file = open('network.s2p')
>>> t = rf.Touchstone(file)
From a io.StringIO object
>>> link = 'https://raw.githubusercontent.com/scikit-rf/scikit-rf/master/examples/
basic_touchstone_plotting/horn antenna.s1p'
>>> r = requests.get(link)
>>> stringio = io.StringIO(r.text)
>>> stringio.name = 'horn.s1p' # must be provided for the Touchstone parser
>>> horn = rf.Touchstone(stringio)
"""
## file format version.
# Defined by default to 1.0, since version number can be omitted in V1.0 format
self._version = "1.0"
## comments in the file header
self.comments = ""
## unit of the frequency (Hz, kHz, MHz, GHz)
self.frequency_unit = None
## number of frequency points
self.frequency_nb = None
## s-parameter type (S,Y,Z,G,H)
self.parameter = None
## s-parameter format (MA, DB, RI)
self.format = None
## reference resistance, global setup
self.resistance = None
## reference impedance for each s-parameter
self.reference = None
## numpy array of original noise data
self.noise = None
## kind of s-parameter data (s1p, s2p, s3p, s4p)
self.rank = None
## Store port names in a list if they exist in the file
self.port_names = None
self.comment_variables = None
# Does the input file has HFSS per frequency port impedances
self.has_hfss_port_impedances = False
self.gamma = None
self.z0 = None
self.s_def = None
self.port_modes = None
# open the file depending on encoding
# Guessing the encoding by trial-and-error, unless specified encoding
try:
try:
if encoding is not None:
fid = get_fid(file, encoding=encoding)
self.filename = fid.name
self.load_file(fid)
else:
# Assume default encoding
fid = get_fid(file)
self.filename = fid.name
self.load_file(fid)
except Exception as e:
fid.close()
raise e
except UnicodeDecodeError:
# Unicode fails -> Force Latin-1
fid = get_fid(file, encoding="ISO-8859-1")
self.filename = fid.name
self.load_file(fid)
except ValueError:
# Assume Microsoft UTF-8 variant encoding with BOM
fid = get_fid(file, encoding="utf-8-sig")
self.filename = fid.name
self.load_file(fid)
except Exception as e:
raise ValueError("Something went wrong by the file opening") from e
finally:
fid.close()
@staticmethod
def _parse_n_floats(*, line: str, fid: typing.TextIO, n: int, before_comment: bool) -> list[float]:
"""Parse a specified number of floats either in our outside a comment.
Args:
line (str): Actual line to parse.
fid (typing.TextIO): File descriptor to get new lines if necessary.
n (int): Number of floats to parse.
before_comment (bool): True, if the floats should get parsed in or outside a comment.
Returns:
list[float]: The parsed float values
"""
def get_part_of_line(line: str) -> str:
"""Return either the part after or before a exclamation mark.
Args:
line (str): Line to parse.
Returns:
str: string subset.
"""
if before_comment:
return line.partition("!")[0]
else:
return line.rpartition("!")[2]
values = get_part_of_line(line).split()
ret = []
while True:
if len(ret) == n:
break
if not values:
values = get_part_of_line(fid.readline()).split()
try:
ret.append(float(values.pop(0)))
except ValueError:
pass
return ret
@property
def version(self) -> str:
"""The version string.
Returns:
str: Version
"""
return self._version
@version.setter
def version(self, x: str) -> None:
self._version = x
if x == "2.0":
self._parse_dict.update(self._parse_dict_v2)
def _parse_file(self, fid: typing.TextIO) -> ParserState:
"""
Parse the raw file and generate an structured view.
Parameters
----------
fid : file object
Returns
-------
state: File content as ParserState
"""
state = ParserState()
# Check the filename extension.
# Should be .sNp for Touchstone format V1.0, and .ts for V2
extension = self.filename.split(".")[-1].lower()
m = re.match(r"s(\d+)p", extension)
if m:
state.rank = int(m.group(1))
elif extension != "ts":
msg = (f"{self.filename} does not have a s-parameter extension ({extension})."
"Please, correct the extension to of form: 'sNp', where N is any integer for Touchstone v1,"
"or ts for Touchstone v2.")
raise ValueError(msg)
# Lookup dictionary for parser
# Dictionary has string keys and values contains functions which
# need the current line as string argument. The type hints allow
# the IDE to provide full typing support
# Take care of the order of the elements when inserting new key words.
self._parse_dict: dict[str, Callable[[str], None]] = {
"[version]": lambda x: setattr(self, "version", x.split()[1]),
"#": lambda x: state.parse_option_line(x),
"! gamma": lambda x: state.hfss_gamma.append(
self._parse_n_floats(line=x, fid=fid, n=state.rank * 2, before_comment=False)
),
"! port impedance": lambda x: state.hfss_impedance.append(
self._parse_n_floats(
line=remove_prefix(x.lower(), "! port impedance"),
fid=fid,
n=state.n_ansys_impedance_values,
before_comment=False,
)
),
"! port": state.parse_port,
"! terminal data exported": lambda _: setattr(state, "ansys_data_type", "terminal"),
"! modal data exported": lambda _: setattr(state, "ansys_data_type", "modal"),
"!": state.append_comment,
}
self._parse_dict_v2: dict[str, Callable[[str], None]] = {
"[number of ports]": lambda x: setattr(state, "rank", int(x.split()[3])),
"[reference]": lambda x: setattr(
state, "resistance", self._parse_n_floats(line=x, fid=fid, n=state.rank, before_comment=True)
),
"[number of frequencies]": lambda x: setattr(self, "frequency_nb", int(x.split()[3])),
"[matrix format]": lambda x: setattr(state, "matrix_format", x.split()[2].lower()),
"[network data]": lambda _: setattr(state, "parse_network", True),
"[noise data]": lambda _: setattr(state, "parse_noise", True),
"[two-port data order]": lambda x: setattr(state, "two_port_order_legacy", "21_12" in x),
"[number of noise frequencies]": lambda x: setattr(
state, "number_noise_freq", int(x.partition("]")[2].strip())
),
"[mixed-mode order]": lambda line: setattr(state, "mixed_mode_order", line.lower().split()[2:]),
"[end]": lambda x: None,
}
while True:
line = fid.readline()
if not line:
break
line_l = line.lower()
for k, v in self._parse_dict.items():
if line_l.startswith(k):
v(line)
break
else:
values = [float(v) for v in line.partition("!")[0].split()]
if not values:
continue
if (
state.f
and len(state.s) % state.numbers_per_line == 0
and values[0] < state.f[-1]
and state.rank == 2
and self.version == "1.0"
):
state.parse_noise = True
if state.parse_network:
if len(state.s) % state.numbers_per_line == 0:
state.f.append(values.pop(0))
state.s.extend(values)
elif state.parse_noise:
state.noise.append(values)
return state
[docs]
def load_file(self, fid: typing.TextIO):
"""
Load the touchstone file into the internal data structures.
Parameters
----------
fid : file object
"""
state = self._parse_file(fid=fid)
self.comments = "\n".join([line.strip()[1:] for line in state.comments])
self.comments_after_option_line = "\n".join([line.strip()[1:] for line in state.comments_after_option_line])
self.rank = state.rank
self.frequency_unit = state.frequency_unit
self.parameter = state.parameter
self.format = state.format
self.resistance = state.resistance
if state.port_names:
self.port_names = [""] * state.rank
for k, v in state.port_names.items():
self.port_names[k] = v
if state.hfss_gamma:
self.gamma = npy.array(state.hfss_gamma).view(npy.complex128)
# Impedance is parsed in the following order:
# - HFSS comments for each frequency point and each port.
# - TS v2 Reference keyword for each port.
# - Reference impedance from option line.
if state.hfss_impedance:
self.z0 = npy.array(state.hfss_impedance).view(npy.complex128)
# Comment the line in, when we need when to expect port impedances in NxN format.
# See https://github.com/scikit-rf/scikit-rf/issues/354 for details.
#if state.ansys_data_type == "terminal":
# self.z0 = npy.diagonal(self.z0.reshape(-1, self.rank, self.rank), axis1=1, axis2=2)
self.s_def = S_DEF_HFSS_DEFAULT
self.has_hfss_port_impedances = True
elif self.reference is None:
self.z0 = npy.broadcast_to(self.resistance, (len(state.f), state.rank)).copy()
else:
self.z0 = npy.empty((len(state.f), state.rank), dtype=complex).fill(self.reference)
self.f = npy.array(state.f)
if not len(self.f):
self.s = npy.empty((0, state.rank, state.rank))
return
raw = npy.array(state.s).reshape(len(self.f), -1)
if self.format == "db":
raw[:, 0::2] = 10 ** (raw[:, 0::2] / 20.0)
if self.format in (["ma", "db"]):
s_flat = raw[:, 0::2] * npy.exp(1j * raw[:, 1::2] * npy.pi / 180)
elif self.format == "ri":
s_flat = raw.view(npy.complex128)
self.s_flat = s_flat
self.s = npy.empty((len(self.f), state.rank * state.rank), dtype=complex)
if state.matrix_format == "full":
self.s[:] = s_flat
else:
index = npy.tril_indices(state.rank) if state.matrix_format == "lower" else npy.triu_indices(self.rank)
index_flat = npy.ravel_multi_index(index, (state.rank, state.rank))
self.s[:, index_flat] = s_flat
if state.rank == 2 and state.two_port_order_legacy:
self.s = npy.transpose(self.s.reshape((-1, state.rank, state.rank)), axes=(0, 2, 1))
else:
self.s = self.s.reshape((-1, state.rank, state.rank))
if state.matrix_format != "full":
self.s = npy.nanmax((self.s, self.s.transpose(0, 2, 1)), axis=0)
self.port_modes = npy.array(["S"] * state.rank)
if state.mixed_mode_order:
new_order = [None] * state.rank
for i, mm in enumerate(state.mixed_mode_order):
if mm.startswith("s"):
new_order[i] = int(mm[1:]) - 1
else:
p1, p2 = sorted([int(e) - 1 for e in mm[1:].split(",")])
if mm.startswith("d"):
new_order[i] = p1
else:
new_order[i] = p2
self.port_modes[new_order[i]] = mm[0].upper()
order = npy.arange(self.rank, dtype=int)
self.s[:, new_order, :] = self.s[:, order, :]
self.s[:, :, new_order] = self.s[:, :, order]
self.z0[:, self.port_modes == "D"] *= 2
self.z0[:, self.port_modes == "C"] /= 2
if self.parameter in ["g", "h", "y", "z"]:
if self.version == "1.0":
self.s = self.s * self.z0[:,:, None]
func_name = f"{self.parameter}2s"
from .. import network
self.s: npy.ndarray = getattr(network, func_name)(self.s, self.z0)
# multiplier from the frequency unit
self.frequency_mult = state.frequency_mult
if state.noise:
self.noise = npy.array(state.noise)
self.noise[:, 0] *= self.frequency_mult
self.f *= self.frequency_mult
@property
def sparameters(self) -> npy.ndarray:
"""Touchstone data in tabular format.
Returns:
npy.ndarray: Frequency and data array.
"""
warnings.warn("This method is deprecated and will be removed.", DeprecationWarning, stacklevel=2)
return npy.hstack((self.f[:, None], self.s_flat.view(npy.float64).reshape(len(self.f), -1)))
[docs]
def get_sparameter_names(self, format: str="ri") -> list[str]:
"""
Generate a list of column names for the s-parameter data.
The names are different for each format.
Parameters
----------
format : str
Format: ri, ma, db
Returns
-------
names : list
list of strings
"""
warnings.warn("This method is deprecated and will be removed.", DeprecationWarning, stacklevel=2)
return self.get_sparameter_data(format).keys()
[docs]
def get_sparameter_data(self, format: str="ri") -> dict[str, npy.ndarray]:
"""
Get the data of the s-parameter with the given format.
Parameters
----------
format : str
Format: ri, ma, db
supported formats are:
ri: data in real/imaginary
ma: data in magnitude and angle (degree)
db: data in log magnitude and angle (degree)
Returns
-------
ret: list
list of numpy.arrays
"""
warnings.warn("This method is deprecated and will be removed.", DeprecationWarning, stacklevel=2)
ret = {"frequency": self.f}
for j in range(self.rank):
for k in range(self.rank):
prefix = f"S{j+1}{k+1}"
val = self.s[:, j, k]
if self.rank == 2 and self.filename.split(".")[-1].lower() == "s2p":
prefix = f"S{k+1}{j+1}"
val = self.s[:, k, j]
if format == "ri":
ret[f"{prefix}R"] = val.real
ret[f"{prefix}I"] = val.imag
elif format == "ma":
ret[f"{prefix}M"] = npy.abs(val)
ret[f"{prefix}A"] = npy.angle(val, deg=True)
elif format == "db":
ret[f"{prefix}DB"] = 20 * npy.log10(npy.abs(val))
ret[f"{prefix}A"] = npy.angle(val, deg=True)
return ret
[docs]
def get_sparameter_arrays(self) -> tuple[npy.ndarray, npy.ndarray]:
"""
Returns the s-parameters as a tuple of arrays.
The first element is the frequency vector (in Hz) and the s-parameters are a 3d numpy array.
The values of the s-parameters are complex number.
Returns
-------
param : tuple of arrays
Examples
--------
>>> f, a = self.sgetparameter_arrays()
>>> s11 = a[:, 0, 0]
"""
return self.f, self.s
[docs]
def get_noise_names(self):
raise NotImplementedError("not yet implemented")
[docs]
def get_noise_data(self):
# TBD = 1
# noise_frequency = noise_values[:,0]
# noise_minimum_figure = noise_values[:,1]
# noise_source_reflection = noise_values[:,2]
# noise_source_phase = noise_values[:,3]
# noise_normalized_resistance = noise_values[:,4]
raise NotImplementedError("not yet implemented")
[docs]
def get_gamma_z0(self):
"""
Extracts Z0 and Gamma comments from touchstone file (if provided).
Returns
-------
gamma : complex npy.ndarray
complex propagation constant
z0 : npy.ndarray
complex port impedance
"""
return self.gamma, self.z0
[docs]
def hfss_touchstone_2_gamma_z0(filename: str) -> tuple[npy.ndarray, npy.ndarray, npy.ndarray]:
"""
Extracts Z0 and Gamma comments from touchstone file.
Takes a HFSS-style Touchstone file with Gamma and Z0 comments and
extracts a triplet of arrays being: (frequency, Gamma, Z0)
Parameters
----------
filename : string
the HFSS-style Touchstone file
Returns
-------
f : npy.ndarray
frequency vector (in Hz)
gamma : complex npy.ndarray
complex propagation constant
z0 : npy.ndarray
complex port impedance
Examples
--------
>>> f,gamm,z0 = rf.hfss_touchstone_2_gamma_z0('line.s2p')
"""
ntwk = Network(filename)
return ntwk.frequency.f, ntwk.gamma, ntwk.z0
[docs]
def hfss_touchstone_2_network(filename: str) -> Network:
"""
Creates a :class:`~skrf.Network` object from a a HFSS-style Touchstone file.
Parameters
----------
filename : string
the HFSS-style Touchstone file
Returns
-------
my_network : :class:`~skrf.network.Network` object
the n-port network model
Examples
--------
>>> my_network = rf.hfss_touchstone_2_network('DUT.s2p')
See Also
--------
hfss_touchstone_2_gamma_z0 : returns gamma, and z0
"""
my_network = Network(file=filename)
return my_network
[docs]
def read_zipped_touchstones(ziparchive: zipfile.ZipFile, dir: str = "") -> dict[str, Network]:
"""
similar to skrf.io.read_all_networks, which works for directories but only for Touchstones in ziparchives.
Parameters
----------
ziparchive : :class:`zipfile.ZipFile`
an zip archive file, containing Touchstone files and open for reading
dir : str
the directory of the ziparchive to read networks from, default is "" which reads only the root directory
Returns
-------
dict
keys are touchstone filenames without extensions
values are network objects created from the touchstone files
"""
networks = dict()
for fname in ziparchive.namelist(): # type: str
directory = os.path.split(fname)[0]
if dir == directory and re.match(r"s\d+p", fname.lower()):
network = Network.zipped_touchstone(fname, ziparchive)
networks[network.name] = network
return networks