"""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)