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.
241 lines
8.7 KiB
Python
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
|