mcltspice/src/mcp_ltspice/touchstone.py
Ryan Malloy ba649d2a6e Add stability, power, optimization, batch, and schematic generation tools
Phase 3 features bringing the server to 27 tools:
- Stepped/multi-run .raw file parsing (.step, .mc, .temp)
- Stability analysis (gain/phase margin from AC loop gain)
- Power analysis (average, RMS, efficiency, power factor)
- Safe waveform expression evaluator (recursive-descent parser)
- Component value optimizer (binary search + coordinate descent)
- Batch simulation: parameter sweep, temperature sweep, Monte Carlo
- .asc schematic generation from templates (RC filter, divider, inverting amp)
- Touchstone .s1p/.s2p/.snp S-parameter file parsing
- 7 new netlist templates (diff amp, common emitter, buck, LDO, oscillator, H-bridge)
- Full ruff lint and format compliance across all modules
2026-02-10 23:05:35 -07:00

255 lines
8.1 KiB
Python

"""Parse Touchstone (.s1p, .s2p, .snp) S-parameter files for LTspice."""
import re
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
# Frequency unit multipliers to Hz
_FREQ_MULTIPLIERS: dict[str, float] = {
"HZ": 1.0,
"KHZ": 1e3,
"MHZ": 1e6,
"GHZ": 1e9,
}
@dataclass
class TouchstoneData:
"""Parsed contents of a Touchstone file.
All frequencies are stored in Hz regardless of the original file's
unit. The ``data`` array holds complex-valued parameters with shape
``(n_freq, n_ports, n_ports)``.
"""
filename: str
n_ports: int
freq_unit: str
parameter_type: str # S, Y, Z, H, G
format_type: str # MA, DB, RI
reference_impedance: float
frequencies: np.ndarray # shape (n_freq,), always in Hz
data: np.ndarray # shape (n_freq, n_ports, n_ports), complex128
comments: list[str] = field(default_factory=list)
def _to_complex(v1: float, v2: float, fmt: str) -> complex:
"""Convert a value pair to complex according to the format type."""
if fmt == "RI":
return complex(v1, v2)
elif fmt == "MA":
mag = v1
angle_rad = np.deg2rad(v2)
return complex(mag * np.cos(angle_rad), mag * np.sin(angle_rad))
elif fmt == "DB":
mag = 10.0 ** (v1 / 20.0)
angle_rad = np.deg2rad(v2)
return complex(mag * np.cos(angle_rad), mag * np.sin(angle_rad))
else:
raise ValueError(f"Unknown format type: {fmt!r}")
def _detect_ports(path: Path) -> int:
"""Detect port count from the file extension (.s<n>p)."""
suffix = path.suffix.lower()
m = re.match(r"\.s(\d+)p$", suffix)
if not m:
raise ValueError(
f"Cannot determine port count from extension {suffix!r}. "
"Expected .s1p, .s2p, .s3p, .s4p, etc."
)
return int(m.group(1))
def parse_touchstone(path: str | Path) -> TouchstoneData:
"""Parse a Touchstone file into a TouchstoneData object.
Handles .s1p through .s4p (and beyond), all three format types
(MA, DB, RI), all frequency units, and continuation lines used
by files with more than two ports.
Args:
path: Path to the Touchstone file.
Returns:
A TouchstoneData with frequencies converted to Hz and
parameters stored as complex128.
"""
path = Path(path)
n_ports = _detect_ports(path)
# Defaults per Touchstone spec
freq_unit = "GHZ"
param_type = "S"
fmt = "MA"
ref_impedance = 50.0
comments: list[str] = []
data_lines: list[str] = []
option_found = False
with path.open() as fh:
for raw_line in fh:
line = raw_line.strip()
# Comment lines
if line.startswith("!"):
comments.append(line[1:].strip())
continue
# Option line (only the first one is used)
if line.startswith("#"):
if not option_found:
option_found = True
tokens = line[1:].split()
# Parse tokens case-insensitively
i = 0
while i < len(tokens):
tok = tokens[i].upper()
if tok in _FREQ_MULTIPLIERS:
freq_unit = tok
elif tok in ("S", "Y", "Z", "H", "G"):
param_type = tok
elif tok in ("MA", "DB", "RI"):
fmt = tok
elif tok == "R" and i + 1 < len(tokens):
i += 1
ref_impedance = float(tokens[i])
i += 1
continue
# Skip blank lines
if not line:
continue
# Inline comments after data (some files use !)
if "!" in line:
line = line[: line.index("!")]
data_lines.append(line)
# Compute the expected number of value pairs per frequency point.
# Each frequency point has n_ports * n_ports parameters, each
# consisting of two floats.
values_per_freq = n_ports * n_ports * 2 # pairs * 2 values each
# Flatten all data tokens
all_tokens: list[float] = []
for dl in data_lines:
all_tokens.extend(float(t) for t in dl.split())
# Each frequency row starts with the frequency value, followed by
# values_per_freq data values. For n_ports <= 2, everything fits
# on one line. For n_ports > 2, continuation lines are used and
# don't repeat the frequency.
stride = 1 + values_per_freq # freq + data
if len(all_tokens) % stride != 0:
raise ValueError(
f"Token count {len(all_tokens)} is not a multiple of "
f"expected stride {stride} for a {n_ports}-port file."
)
n_freq = len(all_tokens) // stride
freq_mult = _FREQ_MULTIPLIERS[freq_unit]
frequencies = np.empty(n_freq, dtype=np.float64)
data = np.empty((n_freq, n_ports, n_ports), dtype=np.complex128)
for k in range(n_freq):
offset = k * stride
frequencies[k] = all_tokens[offset] * freq_mult
# Data values come in pairs: (v1, v2) per parameter
idx = offset + 1
for row in range(n_ports):
for col in range(n_ports):
v1 = all_tokens[idx]
v2 = all_tokens[idx + 1]
data[k, row, col] = _to_complex(v1, v2, fmt)
idx += 2
return TouchstoneData(
filename=path.name,
n_ports=n_ports,
freq_unit=freq_unit,
parameter_type=param_type,
format_type=fmt,
reference_impedance=ref_impedance,
frequencies=frequencies,
data=data,
comments=comments,
)
def get_s_parameter(data: TouchstoneData, i: int, j: int) -> tuple[np.ndarray, np.ndarray]:
"""Extract a single S-parameter across all frequencies.
Args:
data: Parsed Touchstone data.
i: Row index (1-based, as in S(i,j)).
j: Column index (1-based).
Returns:
Tuple of (frequencies_hz, complex_values) where both are 1-D
numpy arrays.
"""
if i < 1 or i > data.n_ports:
raise IndexError(f"Row index {i} out of range for {data.n_ports}-port data")
if j < 1 or j > data.n_ports:
raise IndexError(f"Column index {j} out of range for {data.n_ports}-port data")
return data.frequencies.copy(), data.data[:, i - 1, j - 1].copy()
def s_param_to_db(complex_values: np.ndarray) -> np.ndarray:
"""Convert complex S-parameter values to decibels.
Computes 20 * log10(|S|), flooring magnitudes at -300 dB to avoid
log-of-zero warnings.
Args:
complex_values: Array of complex S-parameter values.
Returns:
Magnitude in dB as a real-valued numpy array.
"""
magnitude = np.abs(complex_values)
return 20.0 * np.log10(np.maximum(magnitude, 1e-15))
def generate_ltspice_subcircuit(touchstone_data: TouchstoneData, name: str) -> str:
"""Generate an LTspice-compatible subcircuit wrapping S-parameter data.
LTspice can reference Touchstone files from within a subcircuit
using the ``.net`` directive. This function produces a ``.sub``
file body that instantiates the S-parameter block.
Args:
touchstone_data: Parsed Touchstone data.
name: Subcircuit name (used in .SUBCKT and filename references).
Returns:
A string containing the complete subcircuit definition.
"""
td = touchstone_data
n = td.n_ports
# Build port list: port1, port2, ..., portN, plus a reference node
port_names = [f"port{k}" for k in range(1, n + 1)]
port_list = " ".join(port_names)
lines: list[str] = []
lines.append(f"* LTspice subcircuit for {td.filename}")
lines.append(f"* {n}-port {td.parameter_type}-parameters, Z0={td.reference_impedance} Ohm")
lines.append(
f"* Frequency range: {td.frequencies[0]:.6g} Hz "
f"to {td.frequencies[-1]:.6g} Hz "
f"({len(td.frequencies)} points)"
)
lines.append(f".SUBCKT {name} {port_list} ref")
lines.append(f".net {td.filename} {port_list} ref")
lines.append(f".ends {name}")
return "\n".join(lines) + "\n"