"""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 # Look for collector voltage vcol_idx = None for var in raw.variables: if var.name.lower() == "time": time_idx = var.index elif "collector" in var.name.lower() or var.name.lower() == "v(collector)": vcol_idx = var.index assert time_idx is not None if vcol_idx is None: # Fall back to any voltage signal that isn't supply for var in raw.variables: if var.name.lower().startswith("v(") and var.name.lower() not in ( "v(vcc)", "v(time)", ): vcol_idx = var.index break assert vcol_idx is not None, ( f"No suitable signal found. 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