import { describe, it, expect } from 'vitest'; import { runNetlist } from '../src/spice/SpiceEngine.js'; /** * Advanced analog circuits that combine passives + transistors + op-amps. * These go beyond textbook building blocks to system-level validation. */ describe('ngspice — Wheatstone bridge', () => { it('balanced bridge (R1·R4 = R2·R3) has zero differential voltage', { timeout: 30_000 }, async () => { // R1=R2=R3=R4=1k → balanced const netlist = `Wheatstone balanced Vcc vcc 0 DC 5 R1 vcc a 1k R2 vcc b 1k R3 a 0 1k R4 b 0 1k .op .end`; const { dcValue } = await runNetlist(netlist); const va = dcValue('v(a)'); const vb = dcValue('v(b)'); expect(Math.abs(va - vb)).toBeLessThan(0.01); }); it('unbalanced bridge (strain-gauge style) produces expected differential', { timeout: 30_000 }, async () => { // R1=1k, R4=1k, R2=1k, R3=1.1k (gauge under strain) const netlist = `Wheatstone unbalanced Vcc vcc 0 DC 5 R1 vcc a 1k R2 vcc b 1k R3 a 0 1.1k R4 b 0 1k .op .end`; const { dcValue } = await runNetlist(netlist); const va = dcValue('v(a)'); const vb = dcValue('v(b)'); // Va = 5·(1.1/2.1) ≈ 2.619; Vb = 5·(1/2) = 2.5 → diff ≈ 0.119 V expect(va - vb).toBeGreaterThan(0.1); expect(va - vb).toBeLessThan(0.14); }); }); describe('ngspice — Schmitt trigger (op-amp based)', () => { it('shows hysteresis: output flips at different thresholds depending on direction', { timeout: 45_000 }, async () => { // Non-inverting Schmitt: + input fed by divider from Vin and Vout through 10k+10k. // Hysteresis band ≈ ±Vsat·(R1/(R1+R2)) — with both 10k → half the output swing. // Triangular input sweeps above and below the thresholds. // Non-inverting Schmitt: input connects through R_in=10k to V+, output // feeds back through R_fb=100k to V+. V_p = (R_fb·V_in + R_in·V_out)/(R_in+R_fb) // = (100·V_in + 10·V_out) / 110. Trip when V_p=0 → V_in = -V_out·(R_in/R_fb) // = ±1 V with V_out=±10 V (hysteresis band = 2 V). // Comparator modelled as a B-source with u() step: V_out = +10 if V_p>0 else -10. const netlist = `Schmitt trigger Vin in 0 PWL(0 -3 5m 3 10m -3) Rin in p 10k Rfb p out 100k Bopa out 0 V = 20 * u(V(p)) - 10 Rload out 0 1Meg .tran 10u 10m .end`; const { vec } = await runNetlist(netlist); const t = vec('time'); const vout = vec('v(out)'); const vin = vec('v(in)'); // Find a rising zero-crossing of input and check output state flipped let sawHigh = false, sawLow = false; for (let i = 0; i < t.length; i++) { if (vout[i] > 5) sawHigh = true; if (vout[i] < -5) sawLow = true; } expect(sawHigh).toBe(true); expect(sawLow).toBe(true); // Hysteresis: find the last-sample Vin at which output went HIGH (rising) // and the last Vin at which it went LOW (falling). They must differ. const transitions = []; for (let i = 1; i < t.length; i++) { if (vout[i - 1] < 0 && vout[i] >= 0) transitions.push({ dir: 'rise', vin: vin[i] }); if (vout[i - 1] > 0 && vout[i] <= 0) transitions.push({ dir: 'fall', vin: vin[i] }); } const risings = transitions.filter(x => x.dir === 'rise'); const fallings = transitions.filter(x => x.dir === 'fall'); expect(risings.length).toBeGreaterThan(0); expect(fallings.length).toBeGreaterThan(0); // The rising-trigger Vin must be strictly greater than the falling-trigger Vin. const riseTrip = risings[0].vin; const fallTrip = fallings[0].vin; expect(riseTrip).toBeGreaterThan(fallTrip); }); }); describe('ngspice — Sallen-Key low-pass filter (op-amp, 2nd order)', () => { it('Butterworth LPF attenuates 10·fc by ≈ 40 dB', { timeout: 60_000 }, async () => { // Sallen-Key LPF: fc = 1 / (2π√(R1·R2·C1·C2)) // R1=R2=10k, C1=C2=10n → fc ≈ 1.59 kHz. Q=0.707 via unity-gain. const netlist = `Sallen-Key LPF Vin in 0 AC 1 R1 in n1 10k R2 n1 p 10k C1 n1 out 10n C2 p 0 10n * Unity-gain buffer Eopa out 0 p out 1e6 .ac dec 20 10 1Meg .end`; const { vec, variableNames } = await runNetlist(netlist); const freq = vec('frequency'); const vout = vec('v(out)'); // Get magnitude at fc and 10·fc const fc = 1 / (2 * Math.PI * Math.sqrt(10e3 * 10e3 * 10e-9 * 10e-9)); const idxFc = nearestIndex(freq, fc); const idx10Fc = nearestIndex(freq, fc * 10); const magFc = magnitude(vout[idxFc]); const mag10Fc = magnitude(vout[idx10Fc]); const attenDb = 20 * Math.log10(magFc / mag10Fc); // 2nd-order LP → ~40 dB/decade roll-off expect(attenDb).toBeGreaterThan(30); expect(attenDb).toBeLessThan(55); // Also verify DC gain ≈ 1 (low frequency) const idxDc = nearestIndex(freq, 50); expect(magnitude(vout[idxDc])).toBeGreaterThan(0.9); expect(magnitude(vout[idxDc])).toBeLessThan(1.1); // Silence unused-var lint void variableNames; }); }); describe('ngspice — difference amplifier (op-amp instrumentation)', () => { it('V_out = (R_f/R_in) · (V_ref − V_in) for matched resistor pairs', { timeout: 30_000 }, async () => { // Standard difference amp: R1=R3=10k on inputs, R2=R4=100k feedback/shunt. // Gain = R2/R1 = 10. V_out = 10·(V_ref - V_in). const netlist = `Difference amp V1 in1 0 DC 0.5 V2 in2 0 DC 0.3 R1 in1 n 10k R2 n out 100k R3 in2 p 10k R4 p 0 100k Eopa out 0 p n 1e6 .op .end`; const { dcValue } = await runNetlist(netlist); // Expected: V_out ≈ 10 · (0.3 − 0.5) = −2.0 expect(dcValue('v(out)')).toBeCloseTo(-2.0, 1); }); }); describe('ngspice — Voltage regulator (zener-based simple shunt)', () => { it('5.1V zener holds the output steady when input varies 8–12V', { timeout: 30_000 }, async () => { for (const vin of [8, 10, 12]) { const netlist = `Zener shunt Vin=${vin} Vcc vcc 0 DC ${vin} Rs vcc out 220 Dz 0 out DZEN .model DZEN D(Is=1n N=1 Rs=5 Bv=5.1 Ibv=50m) .op .end`; const { dcValue } = await runNetlist(netlist); const vout = dcValue('v(out)'); // Should stay within ±0.2 V of 5.1 V across the range expect(vout).toBeGreaterThan(4.8); expect(vout).toBeLessThan(5.4); } }); }); describe('ngspice — RC PWM-to-analog averager', () => { it('RC low-pass averages a 1 kHz 30%-duty PWM to ≈ 30%·Vcc', { timeout: 30_000 }, async () => { const netlist = `PWM avg Vpwm pwm 0 PULSE(0 5 0 1u 1u 0.3m 1m) R1 pwm out 10k C1 out 0 10u IC=0 .tran 100u 300m .end`; const { vec } = await runNetlist(netlist); const t = vec('time'); const vout = vec('v(out)'); // Average over the last 50 ms (well past 3 τ = 300 ms → still settling, but oscillating around target) let sum = 0, count = 0; for (let i = 0; i < t.length; i++) { if (t[i] < 0.25) continue; sum += vout[i]; count++; } const avg = sum / count; // 30% duty × 5V = 1.5V expect(avg).toBeGreaterThan(1.2); expect(avg).toBeLessThan(1.8); }); }); describe('ngspice — integrator (op-amp)', () => { it('square-wave input produces a triangle output at the integrator output', { timeout: 30_000 }, async () => { // Ideal inverting integrator: R=10k, C=0.1µF → τ_i = 1ms // Input: 1 kHz square wave, ±1 V. // Output should be triangular with amplitude ≈ V_in·(T/4)/(R·C) = 1·250µs/1ms = 0.25 V // Miller integrator with a DC-feedback resistor (1 MΩ) in parallel with C // to bound the output. Ideal op-amp via VCVS. IC=0 pins the integrator // start-point to 0 V so the transient doesn't drift. const netlist = `Integrator Vin in 0 PULSE(-1 1 0 1u 1u 0.5m 1m) R1 in n 10k C1 n out 0.1u IC=0 Rfb n out 1Meg Eopa out 0 0 n 1e5 .tran 10u 10m UIC .end`; const { vec } = await runNetlist(netlist); const t = vec('time'); const vin = vec('v(in)'); const vout = vec('v(out)'); // Over steady state, output swing should be within ±0.5 V (triangle peaks) // and should change monotonically between input edges. let peakHi = -Infinity, peakLo = Infinity; for (let i = 0; i < t.length; i++) { if (t[i] < 2e-3) continue; // skip the DC-offset transient if (vout[i] > peakHi) peakHi = vout[i]; if (vout[i] < peakLo) peakLo = vout[i]; } const swing = peakHi - peakLo; // Triangle peak-peak should be around 0.5 V (allowing for startup offset) expect(swing).toBeGreaterThan(0.3); expect(swing).toBeLessThan(1.5); // Silence unused vars void vin; }); }); // ── Helpers ──────────────────────────────────────────────────────────────── function nearestIndex(vec, target) { let best = 0, bestDist = Infinity; for (let i = 0; i < vec.length; i++) { const d = Math.abs((typeof vec[i] === 'number' ? vec[i] : vec[i].real) - target); if (d < bestDist) { bestDist = d; best = i; } } return best; } function magnitude(val) { if (typeof val === 'number') return Math.abs(val); return Math.sqrt(val.real ** 2 + val.img ** 2); }