"""Touchstone v1 (``.sNp``) writer.
Touchstone is the de-facto interchange format for n-port network
parameters; supported by virtually every RF simulator (ADS, AWR,
Sonnet, HFSS, ngspice). The format spec (`IBIS-Open Forum`,
v1.1, 2002) defines a simple option line followed by one row per
frequency point::
# <freq_unit> <param_type> <fmt> R <ref_impedance>
<freq> <p11_a> <p11_b> <p12_a> <p12_b> ...
For 2-port files the entries within a row are ordered
``S11 S21 S12 S22`` (a Touchstone v1 quirk). For higher ports the
ordering is row-major ``S11 S12 ... S1N S21 ...`` with each port row
allowed to span multiple text lines (continued by leading whitespace).
The binary's ``2Port`` REPL command (case 528) prints the same data
in a different textual form; we choose Touchstone because it round-
trips through standard tools and is well documented.
"""
from __future__ import annotations
import math
from collections.abc import Iterable
from dataclasses import dataclass
from io import StringIO
from pathlib import Path
import numpy as np
_FreqUnit = str # "Hz", "kHz", "MHz", "GHz"
[docs]
@dataclass
class TouchstonePoint:
"""One entry in a Touchstone sweep: frequency and the matrix at it."""
freq_ghz: float
matrix: np.ndarray
def _hz_per_unit(unit: _FreqUnit) -> float:
"""How many Hz one ``unit`` represents (1 GHz = 1e9 Hz)."""
return {"Hz": 1.0, "kHz": 1.0e3, "MHz": 1.0e6, "GHz": 1.0e9}[unit]
def _format_pair(z: complex, fmt: str) -> tuple[float, float]:
"""Convert a complex value to the two scalars Touchstone records.
``fmt`` is one of ``"MA"`` (magnitude/angle in degrees), ``"DB"``
(20·log10|z| / angle in degrees), or ``"RI"`` (real/imag).
"""
if fmt == "MA":
mag = abs(z)
ang = math.degrees(math.atan2(z.imag, z.real))
return mag, ang
if fmt == "DB":
mag = abs(z)
db = 20.0 * math.log10(mag) if mag > 0 else -1e6
ang = math.degrees(math.atan2(z.imag, z.real))
return db, ang
if fmt == "RI":
return z.real, z.imag
raise ValueError(f"unknown Touchstone fmt {fmt!r}")
[docs]
def write_touchstone(
points: Iterable[TouchstonePoint],
*,
param: str = "S",
fmt: str = "MA",
z0_ohm: float = 50.0,
freq_unit: _FreqUnit = "GHz",
) -> str:
"""Serialise a sequence of ``TouchstonePoint`` rows to a Touchstone string.
``param`` is one of ``"S"``, ``"Y"``, ``"Z"``. ``fmt`` is
``"MA"`` / ``"DB"`` / ``"RI"``. Z₀ defaults to 50 Ω.
For 2-port matrices the entry order within a row is
``11 21 12 22`` per Touchstone v1 convention; higher-port files
use row-major order ``i1 i2 ... iN`` for each i.
"""
pts = list(points)
if not pts:
raise ValueError("at least one frequency point is required")
n_ports = pts[0].matrix.shape[0]
if any(p.matrix.shape != (n_ports, n_ports) for p in pts):
raise ValueError("all points must share the same matrix shape")
out = StringIO()
out.write(f"# {freq_unit} {param} {fmt} R {z0_ohm:g}\n")
hz_per_unit = _hz_per_unit(freq_unit)
for p in pts:
# Row-major iteration. For 2-port the Touchstone v1 spec
# asks for 11 21 12 22; we honour that by transposing the
# 2x2 case while higher-port files stay row-major.
if n_ports == 2:
order = [(0, 0), (1, 0), (0, 1), (1, 1)]
else:
order = [(i, j) for i in range(n_ports) for j in range(n_ports)]
f_native = p.freq_ghz * 1.0e9 / hz_per_unit
cells = [f"{f_native:.10g}"]
for i, j in order:
a, b = _format_pair(complex(p.matrix[i, j]), fmt)
cells.append(f"{a:.10g}")
cells.append(f"{b:.10g}")
out.write(" ".join(cells) + "\n")
return out.getvalue()
[docs]
def write_touchstone_file(
path: str | Path,
points: Iterable[TouchstonePoint],
**kwargs: object,
) -> None:
"""Write a Touchstone file. ``kwargs`` are forwarded to
:func:`write_touchstone`."""
text = write_touchstone(points, **kwargs) # type: ignore[arg-type]
Path(path).write_text(text)
# Reader ------------------------------------------------------------------
[docs]
@dataclass
class TouchstoneFile:
"""Result of parsing a Touchstone v1 file.
``param`` is one of ``"S"`` / ``"Y"`` / ``"Z"``.
``points`` is the list of per-frequency matrices.
``z0_ohm`` is the reference impedance (typically 50).
``n_ports`` is the matrix dimension.
"""
n_ports: int
param: str
z0_ohm: float
points: list[TouchstonePoint]
def _parse_pair(a: float, b: float, fmt: str) -> complex:
if fmt == "MA":
return a * (math.cos(math.radians(b)) + 1j * math.sin(math.radians(b)))
if fmt == "DB":
mag = 10 ** (a / 20.0)
return mag * (math.cos(math.radians(b)) + 1j * math.sin(math.radians(b)))
if fmt == "RI":
return complex(a, b)
raise ValueError(f"unknown Touchstone fmt {fmt!r}")
[docs]
def read_touchstone(text: str) -> TouchstoneFile:
"""Parse a Touchstone v1 string. Detects port count from line width."""
lines = [ln.strip() for ln in text.splitlines()]
# Skip blank/comment lines until we find the option line
fmt = "MA"
param = "S"
z0_ohm = 50.0
freq_unit = "GHz"
rows: list[list[float]] = []
for ln in lines:
if not ln or ln.startswith("!"):
continue
if ln.startswith("#"):
tokens = ln[1:].split()
for tok in tokens:
tu = tok.upper()
if tu in ("HZ", "KHZ", "MHZ", "GHZ"):
freq_unit = {"HZ": "Hz", "KHZ": "kHz",
"MHZ": "MHz", "GHZ": "GHz"}[tu]
elif tu in ("S", "Y", "Z", "G", "H"):
param = tu
elif tu in ("MA", "DB", "RI"):
fmt = tu
elif tu == "R":
pass # next token is z0
else:
import contextlib
with contextlib.suppress(ValueError):
z0_ohm = float(tok)
continue
rows.append([float(t) for t in ln.split()])
if not rows:
raise ValueError("Touchstone file has no data rows")
# Infer n_ports from row width: 1 freq + n*n*2 scalars
cells_per_row = len(rows[0])
# n*n*2 + 1 = cells → n = sqrt((cells - 1) / 2)
n_squared = (cells_per_row - 1) // 2
n_ports = round(math.sqrt(n_squared))
if n_ports * n_ports * 2 + 1 != cells_per_row:
raise ValueError(
f"row width {cells_per_row} doesn't match a valid port count"
)
# Convert frequency to GHz
hz_per_unit = _hz_per_unit(freq_unit)
points: list[TouchstonePoint] = []
for row in rows:
f_native = row[0]
f_ghz = f_native * hz_per_unit / 1.0e9
# Build the matrix from the 2-scalar pairs
mat = np.zeros((n_ports, n_ports), dtype=complex)
if n_ports == 2:
order = [(0, 0), (1, 0), (0, 1), (1, 1)]
else:
order = [(i, j) for i in range(n_ports) for j in range(n_ports)]
for k, (i, j) in enumerate(order):
a = row[1 + 2 * k]
b = row[2 + 2 * k]
mat[i, j] = _parse_pair(a, b, fmt)
points.append(TouchstonePoint(freq_ghz=f_ghz, matrix=mat))
return TouchstoneFile(
n_ports=n_ports,
param=param,
z0_ohm=z0_ohm,
points=points,
)
[docs]
def read_touchstone_file(path: str | Path) -> TouchstoneFile:
"""Read and parse a Touchstone file from disk."""
return read_touchstone(Path(path).read_text())