Rename package from mcp-ltspice/mcp_ltspice to mcltspice throughout: source directory, imports, pyproject.toml, tests, and README. Remove startup banner prints from main() since FastMCP handles its own banner and stdout is the MCP JSON-RPC transport. Point repo URL at git.supported.systems/MCP/mcltspice.
178 lines
5.8 KiB
Python
178 lines
5.8 KiB
Python
"""Tests for touchstone module: format conversion, parsing, S-parameter extraction."""
|
|
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from mcltspice.touchstone import (
|
|
_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)
|