spicebook/backend/tests/test_ltspice.py
Ryan Malloy 896a8535cf Add LTspice simulation engine via mcltspice
Wire up LTspice as a second simulation engine using mcltspice (Wine-based
LTspice runner). The backend architecture already had the ABC + factory
pattern; this connects the ltspice branch.

- Extract shared raw_to_waveform() into raw_convert.py with Protocol typing
  so both ngspice and mcltspice RawFile objects work without coupling
- Add LtspiceEngine with deferred mcltspice import for graceful degradation
- Register "ltspice" in get_engine() with availability check
  (mcltspice + Wine + LTspice.exe must all be present)
- Add startup validation log for LTspice availability
- Add mcltspice as optional dependency: pip install spicebook[ltspice]
- Integration tests auto-skip when LTspice is unavailable (Docker/CI)
2026-03-05 15:06:41 -07:00

128 lines
3.5 KiB
Python

"""Integration tests for LtspiceEngine with RC circuits.
Skipped automatically when mcltspice + Wine + LTspice aren't available
(e.g., Docker production, CI).
"""
import tempfile
from pathlib import Path
import pytest
# Determine availability at import time for skip markers
_ltspice_available = False
try:
from mcltspice.config import validate_installation
_ltspice_available, _ = validate_installation()
except ImportError:
pass
pytestmark = pytest.mark.skipif(
not _ltspice_available,
reason="LTspice not available (mcltspice + Wine + LTspice.exe required)",
)
from spicebook.engine.ltspice import LtspiceEngine # noqa: E402
RC_TRANSIENT_NETLIST = """\
RC Low-Pass Filter - Transient
V1 in 0 PULSE(0 5 0 1n 1n 5m 10m)
R1 in out 1k
C1 out 0 1u
.tran 100u 20m
.end
"""
RC_AC_NETLIST = """\
RC Low-Pass Filter - AC
V1 in 0 AC 1
R1 in out 1k
C1 out 0 1u
.ac dec 50 1 1Meg
.end
"""
@pytest.fixture
def engine():
return LtspiceEngine()
@pytest.fixture
def work_dir():
d = tempfile.mkdtemp(prefix="spicebook-ltspice-test-")
yield Path(d)
import shutil
shutil.rmtree(d, ignore_errors=True)
@pytest.mark.asyncio
async def test_transient_simulation(engine, work_dir):
"""Run a transient analysis via LTspice and verify waveform output."""
result = await engine.run(RC_TRANSIENT_NETLIST, work_dir)
assert result.success, f"Simulation failed: {result.error}\n{result.log}"
assert result.waveform is not None
assert result.waveform.x_type == "time"
assert result.waveform.points > 0
assert len(result.waveform.x_data) == result.waveform.points
assert result.elapsed_seconds > 0
# Should have at least one signal in y_data
assert len(result.waveform.y_data) > 0
# Check that V(out) or similar exists
signal_names = list(result.waveform.y_data.keys())
has_out = any("out" in name.lower() for name in signal_names)
assert has_out, f"Expected 'out' signal in {signal_names}"
# Verify data length matches
for name, values in result.waveform.y_data.items():
assert len(values) == result.waveform.points, f"Length mismatch for {name}"
@pytest.mark.asyncio
async def test_ac_simulation(engine, work_dir):
"""Run an AC analysis via LTspice and verify complex waveform output."""
result = await engine.run(RC_AC_NETLIST, work_dir)
assert result.success, f"Simulation failed: {result.error}\n{result.log}"
assert result.waveform is not None
assert result.waveform.x_type == "frequency"
assert result.waveform.is_complex
assert result.waveform.points > 0
# AC analysis should produce magnitude and phase data
assert result.waveform.y_magnitude_db is not None
assert result.waveform.y_phase_deg is not None
assert len(result.waveform.y_magnitude_db) > 0
assert len(result.waveform.y_phase_deg) > 0
@pytest.mark.asyncio
async def test_netlist_without_end_directive(engine, work_dir):
"""Engine should auto-append .end if missing."""
netlist_no_end = """\
Simple Resistor Divider
V1 in 0 DC 10
R1 in out 1k
R2 out 0 1k
.tran 1u 1m
"""
result = await engine.run(netlist_no_end, work_dir)
assert result.success, f"Simulation failed: {result.error}\n{result.log}"
@pytest.mark.asyncio
async def test_invalid_netlist(engine, work_dir):
"""Malformed netlist should return success=False with error info."""
bad_netlist = """\
This is not a valid SPICE netlist
.tran 1m 10m
.end
"""
result = await engine.run(bad_netlist, work_dir)
assert isinstance(result.success, bool)