mcltspice/tests/conftest.py
Ryan Malloy cf8394fa6f Rename mcp-ltspice -> mcltspice, remove stdout banner
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.
2026-02-12 22:53:16 -07:00

337 lines
9.6 KiB
Python

"""Shared fixtures for mcltspice test suite.
All fixtures produce synthetic data -- no LTspice or Wine required.
"""
from pathlib import Path
import numpy as np
import pytest
from mcltspice.raw_parser import RawFile, Variable
from mcltspice.schematic import Component, Flag, Schematic, Text, Wire
# ---------------------------------------------------------------------------
# Time-domain fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def sample_rate() -> float:
"""Default sample rate: 100 kHz."""
return 100_000.0
@pytest.fixture
def duration() -> float:
"""Default signal duration: 10 ms (enough for 1 kHz signals)."""
return 0.01
@pytest.fixture
def time_array(sample_rate, duration) -> np.ndarray:
"""Uniformly spaced time array."""
n = int(sample_rate * duration)
return np.linspace(0, duration, n, endpoint=False)
@pytest.fixture
def sine_1khz(time_array) -> np.ndarray:
"""1 kHz sine wave, 1 V peak."""
return np.sin(2 * np.pi * 1000 * time_array)
@pytest.fixture
def dc_signal() -> np.ndarray:
"""Constant 3.3 V DC signal (1000 samples)."""
return np.full(1000, 3.3)
@pytest.fixture
def step_signal(time_array) -> np.ndarray:
"""Unit step at t = duration/2 with exponential rise (tau = duration/10)."""
t = time_array
mid = t[-1] / 2
tau = t[-1] / 10
sig = np.where(t >= mid, 1.0 - np.exp(-(t - mid) / tau), 0.0)
return sig
# ---------------------------------------------------------------------------
# Frequency-domain / AC fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def ac_frequency() -> np.ndarray:
"""Log-spaced frequency array: 1 Hz to 10 MHz, 500 points."""
return np.logspace(0, 7, 500)
@pytest.fixture
def lowpass_response(ac_frequency) -> np.ndarray:
"""First-order lowpass magnitude in dB (fc ~ 1 kHz)."""
fc = 1000.0
mag = 1.0 / np.sqrt(1.0 + (ac_frequency / fc) ** 2)
return 20.0 * np.log10(mag)
@pytest.fixture
def lowpass_complex(ac_frequency) -> np.ndarray:
"""First-order lowpass as complex transfer function (fc ~ 1 kHz)."""
fc = 1000.0
s = 1j * ac_frequency / fc
return 1.0 / (1.0 + s)
# ---------------------------------------------------------------------------
# Stepped / multi-run fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def stepped_time() -> np.ndarray:
"""Time axis for 3 runs, each 0..1 ms with 100 points per run."""
runs = []
for _ in range(3):
runs.append(np.linspace(0, 1e-3, 100, endpoint=False))
return np.concatenate(runs)
@pytest.fixture
def stepped_data(stepped_time) -> np.ndarray:
"""Two variables (time + V(out)) across 3 runs."""
n = len(stepped_time)
data = np.zeros((2, n))
data[0] = stepped_time
# Each run has a different amplitude sine wave
for run_idx in range(3):
start = run_idx * 100
end = start + 100
t_run = data[0, start:end]
data[1, start:end] = (run_idx + 1) * np.sin(2 * np.pi * 1000 * t_run)
return data
# ---------------------------------------------------------------------------
# Mock RawFile fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_rawfile(time_array, sine_1khz) -> RawFile:
"""A simple transient-analysis RawFile with time and V(out)."""
n = len(time_array)
data = np.zeros((2, n))
data[0] = time_array
data[1] = sine_1khz
return RawFile(
title="Test Circuit",
date="2026-01-01",
plotname="Transient Analysis",
flags=["real"],
variables=[
Variable(0, "time", "time"),
Variable(1, "V(out)", "voltage"),
],
points=n,
data=data,
)
@pytest.fixture
def mock_rawfile_stepped(stepped_data) -> RawFile:
"""A stepped RawFile with 3 runs."""
n = stepped_data.shape[1]
return RawFile(
title="Stepped Sim",
date="2026-01-01",
plotname="Transient Analysis",
flags=["real", "stepped"],
variables=[
Variable(0, "time", "time"),
Variable(1, "V(out)", "voltage"),
],
points=n,
data=stepped_data,
n_runs=3,
run_boundaries=[0, 100, 200],
)
@pytest.fixture
def mock_rawfile_ac(ac_frequency, lowpass_complex) -> RawFile:
"""An AC-analysis RawFile with complex frequency-domain data."""
n = len(ac_frequency)
data = np.zeros((2, n), dtype=np.complex128)
data[0] = ac_frequency.astype(np.complex128)
data[1] = lowpass_complex
return RawFile(
title="AC Sim",
date="2026-01-01",
plotname="AC Analysis",
flags=["complex"],
variables=[
Variable(0, "frequency", "frequency"),
Variable(1, "V(out)", "voltage"),
],
points=n,
data=data,
)
# ---------------------------------------------------------------------------
# Netlist / Schematic string fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def sample_netlist_str() -> str:
"""A basic SPICE netlist string for an RC lowpass."""
return (
"* RC Lowpass Filter\n"
"V1 in 0 AC 1\n"
"R1 in out 1k\n"
"C1 out 0 100n\n"
".ac dec 100 1 1meg\n"
".backanno\n"
".end\n"
)
@pytest.fixture
def sample_asc_str() -> str:
"""A minimal .asc schematic string for an RC lowpass."""
return (
"Version 4\n"
"SHEET 1 880 680\n"
"WIRE 80 96 176 96\n"
"WIRE 176 176 272 176\n"
"FLAG 80 176 0\n"
"FLAG 272 240 0\n"
"FLAG 176 176 out\n"
"SYMBOL voltage 80 80 R0\n"
"SYMATTR InstName V1\n"
"SYMATTR Value AC 1\n"
"SYMBOL res 160 80 R0\n"
"SYMATTR InstName R1\n"
"SYMATTR Value 1k\n"
"SYMBOL cap 256 176 R0\n"
"SYMATTR InstName C1\n"
"SYMATTR Value 100n\n"
"TEXT 80 296 Left 2 !.ac dec 100 1 1meg\n"
)
# ---------------------------------------------------------------------------
# Touchstone fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def sample_s2p_content() -> str:
"""Synthetic .s2p file content (MA format, GHz)."""
lines = [
"! Two-port S-parameter data",
"! Freq S11(mag) S11(ang) S21(mag) S21(ang) S12(mag) S12(ang) S22(mag) S22(ang)",
"# GHZ S MA R 50",
"1.0 0.5 -30 0.9 -10 0.1 170 0.4 -40",
"2.0 0.6 -50 0.8 -20 0.12 160 0.5 -60",
"3.0 0.7 -70 0.7 -30 0.15 150 0.55 -80",
]
return "\n".join(lines) + "\n"
@pytest.fixture
def tmp_s2p_file(sample_s2p_content, tmp_path) -> Path:
"""Write synthetic .s2p content to a temp file and return path."""
p = tmp_path / "test.s2p"
p.write_text(sample_s2p_content)
return p
# ---------------------------------------------------------------------------
# Schematic object fixtures (for DRC and diff tests)
# ---------------------------------------------------------------------------
@pytest.fixture
def valid_schematic() -> Schematic:
"""A schematic with ground, components, wires, and sim directive."""
sch = Schematic()
sch.flags = [
Flag(80, 176, "0"),
Flag(272, 240, "0"),
Flag(176, 176, "out"),
]
sch.wires = [
Wire(80, 96, 176, 96),
Wire(176, 176, 272, 176),
]
sch.components = [
Component(name="V1", symbol="voltage", x=80, y=80, rotation=0, mirror=False,
attributes={"Value": "AC 1"}),
Component(name="R1", symbol="res", x=160, y=80, rotation=0, mirror=False,
attributes={"Value": "1k"}),
Component(name="C1", symbol="cap", x=256, y=176, rotation=0, mirror=False,
attributes={"Value": "100n"}),
]
sch.texts = [
Text(80, 296, ".ac dec 100 1 1meg", type="spice"),
]
return sch
@pytest.fixture
def schematic_no_ground() -> Schematic:
"""A schematic missing a ground node."""
sch = Schematic()
sch.flags = [Flag(176, 176, "out")]
sch.wires = [Wire(80, 96, 176, 96)]
sch.components = [
Component(name="R1", symbol="res", x=160, y=80, rotation=0, mirror=False,
attributes={"Value": "1k"}),
]
sch.texts = [Text(80, 296, ".tran 10m", type="spice")]
return sch
@pytest.fixture
def schematic_no_sim() -> Schematic:
"""A schematic missing a simulation directive."""
sch = Schematic()
sch.flags = [Flag(80, 176, "0")]
sch.wires = [Wire(80, 96, 176, 96)]
sch.components = [
Component(name="R1", symbol="res", x=160, y=80, rotation=0, mirror=False,
attributes={"Value": "1k"}),
]
sch.texts = []
return sch
@pytest.fixture
def schematic_duplicate_names() -> Schematic:
"""A schematic with duplicate component names."""
sch = Schematic()
sch.flags = [Flag(80, 176, "0")]
sch.wires = [Wire(80, 96, 176, 96)]
sch.components = [
Component(name="R1", symbol="res", x=160, y=80, rotation=0, mirror=False,
attributes={"Value": "1k"}),
Component(name="R1", symbol="res", x=320, y=80, rotation=0, mirror=False,
attributes={"Value": "2.2k"}),
]
sch.texts = [Text(80, 296, ".tran 10m", type="spice")]
return sch
@pytest.fixture
def ltspice_available():
"""Skip test if LTspice is not available."""
from mcltspice.config import validate_installation
ok, msg = validate_installation()
if not ok:
pytest.skip(f"LTspice not available: {msg}")
return True