mcltspice/tests/test_integration.py
Ryan Malloy b16c20c2ca Fix CE amp coupling cap routing and Colpitts test variable selection
CE amplifier schematic: the input coupling cap CC_in was placed
horizontally (R90) at y=336 — the same y as the RB1-to-base bias
wire. Both cap pins sat on the wire, shorting the cap and allowing
Vin's 0V DC to override the bias divider, putting Q1 in cutoff.

Fix: move CC_in to vertical orientation (R0) above the base wire.
Now pinA=(400,256) and pinB=(400,320) are off the y=336 bias path.
The cap properly blocks DC while passing the 1kHz input signal.
Result: V(out) swings 2.2Vpp (gain ≈ 110) instead of stuck at Vcc.

Colpitts oscillator test: the schematic was actually working (V(out)
pp=2.05V) but the test's fallback variable selection picked V(n001)
(the Vcc rail, constant 12V) instead of V(out). Fix: look for V(out)
first since the schematic labels the collector with "out".

Integration tests: 4/4 pass, unit tests: 360/360 pass.
2026-02-11 06:01:30 -07:00

210 lines
7.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 mcp_ltspice.asc_generator import (
generate_colpitts_oscillator as generate_colpitts_asc,
)
from mcp_ltspice.asc_generator import (
generate_common_emitter_amp as generate_ce_amp_asc,
)
from mcp_ltspice.asc_generator import (
generate_non_inverting_amp as generate_noninv_amp_asc,
)
from mcp_ltspice.asc_generator import (
generate_rc_lowpass as generate_rc_lowpass_asc,
)
from mcp_ltspice.runner import run_simulation
from mcp_ltspice.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