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)
128 lines
3.5 KiB
Python
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)
|