mcltspice/tests/test_touchstone.py
2026-02-10 23:35:53 -07:00

180 lines
5.9 KiB
Python

"""Tests for touchstone module: format conversion, parsing, S-parameter extraction."""
import re
from pathlib import Path
import numpy as np
import pytest
from mcp_ltspice.touchstone import (
TouchstoneData,
_detect_ports,
_to_complex,
get_s_parameter,
parse_touchstone,
s_param_to_db,
)
class TestToComplex:
def test_ri_format(self):
"""RI: (real, imag) -> complex."""
c = _to_complex(3.0, 4.0, "RI")
assert c == complex(3.0, 4.0)
def test_ma_format(self):
"""MA: (magnitude, angle_deg) -> complex."""
c = _to_complex(1.0, 0.0, "MA")
assert c == pytest.approx(complex(1.0, 0.0), abs=1e-10)
c90 = _to_complex(1.0, 90.0, "MA")
assert c90.real == pytest.approx(0.0, abs=1e-10)
assert c90.imag == pytest.approx(1.0, abs=1e-10)
def test_db_format(self):
"""DB: (mag_db, angle_deg) -> complex."""
# 0 dB = magnitude 1.0
c = _to_complex(0.0, 0.0, "DB")
assert abs(c) == pytest.approx(1.0)
# 20 dB = magnitude 10.0
c20 = _to_complex(20.0, 0.0, "DB")
assert abs(c20) == pytest.approx(10.0, rel=0.01)
def test_unknown_format_raises(self):
with pytest.raises(ValueError, match="Unknown format"):
_to_complex(1.0, 0.0, "XY")
class TestDetectPorts:
@pytest.mark.parametrize(
"suffix, expected",
[
(".s1p", 1),
(".s2p", 2),
(".s3p", 3),
(".s4p", 4),
(".S2P", 2), # case insensitive
],
)
def test_valid_extensions(self, suffix, expected):
p = Path(f"test{suffix}")
assert _detect_ports(p) == expected
def test_invalid_extension(self):
with pytest.raises(ValueError, match="Cannot determine port count"):
_detect_ports(Path("test.txt"))
class TestParseTouchstone:
def test_parse_s2p(self, tmp_s2p_file):
"""Parse a synthetic .s2p file and verify structure."""
data = parse_touchstone(tmp_s2p_file)
assert data.n_ports == 2
assert data.parameter_type == "S"
assert data.format_type == "MA"
assert data.reference_impedance == 50.0
assert len(data.frequencies) == 3
assert data.data.shape == (3, 2, 2)
def test_frequencies_in_hz(self, tmp_s2p_file):
"""Frequencies should be converted to Hz (from GHz)."""
data = parse_touchstone(tmp_s2p_file)
# First freq is 1.0 GHz = 1e9 Hz
assert data.frequencies[0] == pytest.approx(1e9)
assert data.frequencies[1] == pytest.approx(2e9)
assert data.frequencies[2] == pytest.approx(3e9)
def test_s11_values(self, tmp_s2p_file):
"""S11 at first frequency should match input: mag=0.5, angle=-30."""
data = parse_touchstone(tmp_s2p_file)
s11 = data.data[0, 0, 0]
assert abs(s11) == pytest.approx(0.5, rel=0.01)
assert np.degrees(np.angle(s11)) == pytest.approx(-30.0, abs=1.0)
def test_comments_parsed(self, tmp_s2p_file):
data = parse_touchstone(tmp_s2p_file)
assert len(data.comments) > 0
def test_s1p_file(self, tmp_path):
"""Parse a minimal .s1p file."""
content = (
"# MHZ S RI R 50\n"
"100 0.5 0.3\n"
"200 0.4 0.2\n"
)
p = tmp_path / "test.s1p"
p.write_text(content)
data = parse_touchstone(p)
assert data.n_ports == 1
assert data.data.shape == (2, 1, 1)
# 100 MHz = 100e6 Hz
assert data.frequencies[0] == pytest.approx(100e6)
def test_db_format_file(self, tmp_path):
"""Parse a .s1p file in DB format."""
content = (
"# GHZ S DB R 50\n"
"1.0 -3.0 -45\n"
"2.0 -6.0 -90\n"
)
p = tmp_path / "dbtest.s1p"
p.write_text(content)
data = parse_touchstone(p)
assert data.format_type == "DB"
# -3 dB -> magnitude ~ 0.707
assert abs(data.data[0, 0, 0]) == pytest.approx(10 ** (-3.0 / 20.0), rel=0.01)
class TestSParamToDb:
def test_unity_magnitude(self):
"""Magnitude 1.0 -> 0 dB."""
vals = np.array([1.0 + 0j])
db = s_param_to_db(vals)
assert db[0] == pytest.approx(0.0, abs=0.01)
def test_known_magnitude(self):
"""Magnitude 0.1 -> -20 dB."""
vals = np.array([0.1 + 0j])
db = s_param_to_db(vals)
assert db[0] == pytest.approx(-20.0, abs=0.1)
def test_zero_magnitude(self):
"""Zero magnitude should not produce -inf (floored)."""
vals = np.array([0.0 + 0j])
db = s_param_to_db(vals)
assert np.isfinite(db[0])
assert db[0] < -200
class TestGetSParameter:
def test_1_based_indexing(self, tmp_s2p_file):
data = parse_touchstone(tmp_s2p_file)
freqs, vals = get_s_parameter(data, 1, 1)
assert len(freqs) == 3
assert len(vals) == 3
def test_s21(self, tmp_s2p_file):
data = parse_touchstone(tmp_s2p_file)
# S21 is stored at (row=0, col=1) -> get_s_parameter(data, 1, 2)
# because the parser iterates row then col, and Touchstone 2-port
# order is S11, S21, S12, S22 -> (0,0), (0,1), (1,0), (1,1)
freqs, vals = get_s_parameter(data, 1, 2)
# S21 at first freq: mag=0.9, angle=-10
assert abs(vals[0]) == pytest.approx(0.9, rel=0.01)
def test_out_of_range_row(self, tmp_s2p_file):
data = parse_touchstone(tmp_s2p_file)
with pytest.raises(IndexError, match="Row index"):
get_s_parameter(data, 3, 1)
def test_out_of_range_col(self, tmp_s2p_file):
data = parse_touchstone(tmp_s2p_file)
with pytest.raises(IndexError, match="Column index"):
get_s_parameter(data, 1, 3)
def test_zero_index_raises(self, tmp_s2p_file):
data = parse_touchstone(tmp_s2p_file)
with pytest.raises(IndexError):
get_s_parameter(data, 0, 1)