mcltspice/tests/test_integration.py
Ryan Malloy 9c252606f7 Fix SameFileError in batch sweeps; guard self-copy in runner
run_parameter_sweep/run_monte_carlo write each variant .cir into a work_dir,
then call run_netlist with that same work_dir -- which tried to shutil.copy2
the file onto itself (SameFileError), so every batch run failed before
simulating. Add _safe_copy() that skips when src and dst resolve to the same
path, used at all four staging-copy sites in run_simulation/run_netlist.

Caught by headless claude-cli testing of the batch tools (no batch test
existed). Adds test_runner.py (_safe_copy unit tests) and a TestBatchSweeps
integration class.
2026-06-21 22:24:24 -06:00

241 lines
8.7 KiB
Python

"""Integration tests that run actual LTspice simulations.
These tests require LTspice and Wine to be installed. Skip with:
pytest -m 'not integration'
"""
import tempfile
from pathlib import Path
import numpy as np
import pytest
from mcltspice.asc_generator import (
generate_colpitts_oscillator as generate_colpitts_asc,
)
from mcltspice.asc_generator import (
generate_common_emitter_amp as generate_ce_amp_asc,
)
from mcltspice.asc_generator import (
generate_non_inverting_amp as generate_noninv_amp_asc,
)
from mcltspice.asc_generator import (
generate_rc_lowpass as generate_rc_lowpass_asc,
)
from mcltspice.runner import run_simulation
from mcltspice.waveform_math import compute_bandwidth, compute_rms
@pytest.mark.integration
class TestRCLowpass:
"""End-to-end test: RC lowpass filter -> simulate -> verify -3dB point."""
async def test_rc_lowpass_bandwidth(self, ltspice_available):
"""Generate RC lowpass (R=1k, C=100n), simulate, verify fc ~ 1.6 kHz."""
# Generate schematic
sch = generate_rc_lowpass_asc(r="1k", c="100n")
with tempfile.TemporaryDirectory() as tmpdir:
asc_path = Path(tmpdir) / "rc_lowpass.asc"
sch.save(asc_path)
# Simulate
result = await run_simulation(asc_path)
assert result.success, f"Simulation failed: {result.error or result.stderr}"
assert result.raw_data is not None, "No raw data produced"
# Find frequency and V(out) variables
raw = result.raw_data
freq_idx = None
vout_idx = None
for var in raw.variables:
if var.name.lower() == "frequency":
freq_idx = var.index
elif var.name.lower() == "v(out)":
vout_idx = var.index
assert freq_idx is not None, (
f"No frequency variable. Variables: {[v.name for v in raw.variables]}"
)
assert vout_idx is not None, (
f"No V(out) variable. Variables: {[v.name for v in raw.variables]}"
)
# Extract data
freq = np.abs(raw.data[freq_idx])
mag_complex = raw.data[vout_idx]
mag_db = 20.0 * np.log10(np.maximum(np.abs(mag_complex), 1e-30))
# Compute bandwidth
bw = compute_bandwidth(freq, mag_db)
# Expected: fc = 1/(2*pi*1000*100e-9) ~ 1591 Hz
# Allow 20% tolerance for simulation differences
expected_fc = 1.0 / (2 * np.pi * 1000 * 100e-9)
assert bw["bandwidth_hz"] is not None, "Could not compute bandwidth"
assert abs(bw["bandwidth_hz"] - expected_fc) / expected_fc < 0.2, (
f"Bandwidth {bw['bandwidth_hz']:.0f} Hz too far from expected {expected_fc:.0f} Hz"
)
@pytest.mark.integration
class TestNonInvertingAmp:
"""End-to-end test: non-inverting amp -> simulate -> verify gain."""
async def test_noninv_amp_gain(self, ltspice_available):
"""Generate non-inverting amp (Rf=100k, Rin=10k), verify gain ~ 11 (20.8 dB)."""
sch = generate_noninv_amp_asc(rin="10k", rf="100k")
with tempfile.TemporaryDirectory() as tmpdir:
asc_path = Path(tmpdir) / "noninv_amp.asc"
sch.save(asc_path)
result = await run_simulation(asc_path)
assert result.success, f"Simulation failed: {result.error or result.stderr}"
assert result.raw_data is not None
raw = result.raw_data
freq_idx = None
vout_idx = None
for var in raw.variables:
if var.name.lower() == "frequency":
freq_idx = var.index
elif var.name.lower() == "v(out)":
vout_idx = var.index
assert freq_idx is not None
assert vout_idx is not None
_freq = np.abs(raw.data[freq_idx]) # noqa: F841 — kept for debug
mag = np.abs(raw.data[vout_idx])
mag_db = 20.0 * np.log10(np.maximum(mag, 1e-30))
# At low frequency, gain should be 1 + 100k/10k = 11 = 20.83 dB
# Use first few points (low frequency)
low_freq_gain_db = float(np.mean(mag_db[:5]))
expected_gain_db = 20 * np.log10(11) # 20.83 dB
assert abs(low_freq_gain_db - expected_gain_db) < 2.0, (
f"Low-freq gain {low_freq_gain_db:.1f} dB, expected ~{expected_gain_db:.1f} dB"
)
@pytest.mark.integration
class TestCommonEmitterAmp:
"""End-to-end test: CE amplifier -> simulate transient -> verify output exists."""
async def test_ce_amp_output(self, ltspice_available):
"""Generate CE amp, simulate transient, verify output has AC content."""
sch = generate_ce_amp_asc()
with tempfile.TemporaryDirectory() as tmpdir:
asc_path = Path(tmpdir) / "ce_amp.asc"
sch.save(asc_path)
result = await run_simulation(asc_path)
assert result.success, f"Simulation failed: {result.error or result.stderr}"
assert result.raw_data is not None
raw = result.raw_data
time_idx = None
vout_idx = None
for var in raw.variables:
if var.name.lower() == "time":
time_idx = var.index
elif var.name.lower() == "v(out)":
vout_idx = var.index
assert time_idx is not None
assert vout_idx is not None
sig = np.real(raw.data[vout_idx])
# Output should not be DC-only -- check peak-to-peak > threshold
pp = float(np.max(sig) - np.min(sig))
assert pp > 0.01, (
f"Output appears DC-only (peak-to-peak={pp:.4f}V). "
"Expected amplified AC signal."
)
# RMS should be non-trivial
rms = float(compute_rms(sig))
assert rms > 0.01, f"Output RMS too low: {rms:.4f}V"
@pytest.mark.integration
class TestColpittsOscillator:
"""End-to-end test: Colpitts oscillator -> simulate -> verify oscillation."""
async def test_colpitts_oscillation(self, ltspice_available):
"""Generate Colpitts oscillator, verify oscillation near expected frequency."""
sch = generate_colpitts_asc()
with tempfile.TemporaryDirectory() as tmpdir:
asc_path = Path(tmpdir) / "colpitts.asc"
sch.save(asc_path)
result = await run_simulation(asc_path)
assert result.success, f"Simulation failed: {result.error or result.stderr}"
assert result.raw_data is not None
raw = result.raw_data
time_idx = None
vcol_idx = None
for var in raw.variables:
if var.name.lower() == "time":
time_idx = var.index
elif var.name.lower() == "v(out)":
vcol_idx = var.index
elif "collector" in var.name.lower() and vcol_idx is None:
vcol_idx = var.index
assert time_idx is not None
assert vcol_idx is not None, (
f"No V(out) or collector signal. Variables: {[v.name for v in raw.variables]}"
)
sig = np.real(raw.data[vcol_idx])
# Oscillator output should have significant AC content
pp = float(np.max(sig) - np.min(sig))
assert pp > 0.1, (
f"Output peak-to-peak {pp:.3f}V too small -- oscillator may not have started"
)
# Expected frequency: f = 1/(2*pi*sqrt(L*C1*C2/(C1+C2)))
# With L=1u, C1=C2=100p: Cseries = 50p
# f = 1/(2*pi*sqrt(1e-6 * 50e-12)) ~ 22.5 MHz
# This is quite high, but we just verify oscillation exists
@pytest.mark.integration
class TestBatchSweeps:
"""End-to-end batch runs (regression for the SameFileError staging bug)."""
_RC = (
"* RC lowpass sweep\n"
".param Rval=1k\n"
"V1 in 0 AC 1\n"
"R1 in out {Rval}\n"
"C1 out 0 159n\n"
".ac dec 10 1 1meg\n"
".end\n"
)
async def test_parameter_sweep_runs_all(self, ltspice_available):
from mcltspice.batch import run_parameter_sweep
result = await run_parameter_sweep(
self._RC, "Rval", [500.0, 1000.0, 2000.0], timeout=90
)
assert result.failure_count == 0, f"sweep failures: {result.failure_count}"
assert result.success_count == 3
async def test_monte_carlo_runs_all(self, ltspice_available):
from mcltspice.batch import run_monte_carlo
result = await run_monte_carlo(self._RC, 4, {"R1": 0.05}, timeout=90, seed=1)
assert result.failure_count == 0, f"mc failures: {result.failure_count}"
assert result.success_count == 4