diff --git a/frontend/src/data/examples-analog.ts b/frontend/src/data/examples-analog.ts index 4ab40c82..7aa61adf 100644 --- a/frontend/src/data/examples-analog.ts +++ b/frontend/src/data/examples-analog.ts @@ -429,10 +429,10 @@ export const analogExamples: ExampleProject[] = [ ], [ w('w1', ['src', 'SIG'], ['c1', '1'], C_SIG), - w('w2', ['c1', '2'], ['d1', 'A'], C_WIRE), - w('w3', ['d1', 'C'], ['src', 'GND'], C_GND), - w('w4', ['c1', '2'], ['d2', 'A'], C_WIRE), - w('w5', ['d2', 'C'], ['c2', '1'], C_OUT), + w('w2', ['c1', '2'], ['d1', 'C'], C_WIRE), // D1 cathode = n1 → clamps negative excursion to ~−0.7 + w('w3', ['d1', 'A'], ['src', 'GND'], C_GND), // D1 anode = GND + w('w4', ['c1', '2'], ['d2', 'A'], C_WIRE), // D2 anode = n1 (now swings 0 → +2·Vpeak) + w('w5', ['d2', 'C'], ['c2', '1'], C_OUT), // D2 cathode → vout w('w6', ['c2', '1'], ['rl', '1'], C_OUT), w('w7', ['c2', '2'], ['src', 'GND'], C_GND), w('w8', ['rl', '2'], ['src', 'GND'], C_GND), diff --git a/test/test_circuit/test/analog_examples.test.js b/test/test_circuit/test/analog_examples.test.js new file mode 100644 index 00000000..610722c0 --- /dev/null +++ b/test/test_circuit/test/analog_examples.test.js @@ -0,0 +1,656 @@ +import { describe, it, expect } from 'vitest'; +import { runNetlist } from '../src/spice/SpiceEngine.js'; + +/** + * SPICE behavior tests, one per analog example shipped in + * frontend/src/data/examples-analog.ts. + * + * Each test mirrors the Velxio circuit as a hand-written ngspice netlist using + * the same component models the NetlistBuilder emits — so a passing test here + * proves the topology converges with our model parameters and produces sane + * voltages/currents. If a model in componentToSpice.ts changes (e.g. BJT Bf, + * LM358 vsat), update the assertions here as well. + * + * Models reused throughout (kept in lockstep with componentToSpice.ts): + * .model D1N4007 D(Is=76.9n N=1.45 Rs=0.0342 Ikf=2.34 Bv=1000 Ibv=5u) + * .model D1N4733 D(Is=1n N=1 Rs=5 Bv=5.1 Ibv=50m) + * .model Q2N2222 NPN(Is=14.34f Bf=200 Vaf=74 Rb=10 Rc=1) + * .model QBC557 PNP(Is=6.73f Bf=250 Vaf=80 Rb=10) + * .model M2N7000 NMOS(Level=1 Vto=1.6 Kp=50u Lambda=0.01) + * .model MIRF9540 PMOS(Level=1 Vto=-3 Kp=20u Lambda=0.01) + * LM358: behavioral B-source, A=1e5, vLo=0.05, vHi=Vcc-1.5 + */ + +const D1N4007 = '.model D1N4007 D(Is=76.9n N=1.45 Rs=0.0342 Ikf=2.34 Bv=1000 Ibv=5u)'; +const D1N4733 = '.model D1N4733 D(Is=1n N=1 Rs=5 Bv=5.1 Ibv=50m)'; +const Q2N2222 = '.model Q2N2222 NPN(Is=14.34f Bf=200 Vaf=74 Rb=10 Rc=1)'; +const QBC557 = '.model QBC557 PNP(Is=6.73f Bf=250 Vaf=80 Rb=10)'; +const M2N7000 = '.model M2N7000 NMOS(Level=1 Vto=1.6 Kp=50u Lambda=0.01)'; +const MIRF9540 = '.model MIRF9540 PMOS(Level=1 Vto=-3 Kp=20u Lambda=0.01)'; + +// ════════════════════════════════════════════════════════════════════════════ +// PASSIVE (1–7) +// ════════════════════════════════════════════════════════════════════════════ + +describe('an-voltage-divider', () => { + it('two equal R: V_out = Vsrc/2', { timeout: 30_000 }, async () => { + const { dcValue } = await runNetlist(`Voltage divider 5V +V_src vsig 0 DC 5 +R_r1 vsig vout 10000 +R_r2 vout 0 10000 +.op +.end`); + expect(dcValue('v(vout)')).toBeCloseTo(2.5, 2); + }); +}); + +describe('an-series-resistors', () => { + it('1k+2.2k+4.7k = 7.9k: I = 10V/7.9k ≈ 1.27 mA', { timeout: 30_000 }, async () => { + const { vec } = await runNetlist(`Series Rs +V_src vsig 0 DC 10 +R_r1 vsig n1 1000 +R_r2 n1 n2 2200 +R_r3 n2 0 4700 +.op +.end`); + const i = Math.abs(vec('i(v_src)')[0]); + expect(i).toBeCloseTo(10 / 7900, 4); + }); +}); + +describe('an-parallel-resistors', () => { + it('three 1k in parallel = 333Ω: I @ 5V ≈ 15 mA', { timeout: 30_000 }, async () => { + const { vec } = await runNetlist(`Parallel Rs +V_src vsig 0 DC 5 +R_r1 vsig 0 1000 +R_r2 vsig 0 1000 +R_r3 vsig 0 1000 +.op +.end`); + const i = Math.abs(vec('i(v_src)')[0]); + expect(i).toBeCloseTo(0.015, 3); + }); +}); + +describe('an-rc-low-pass', () => { + it('R=1.6k, C=100n: 1 kHz sine attenuated near −3 dB', { timeout: 60_000 }, async () => { + // fc = 1/(2π·R·C) = 1/(2π·1.6k·100n) ≈ 995 Hz → at 1 kHz, |H| ≈ 0.707 + const { vec } = await runNetlist(`RC LPF +V_src vsig 0 SIN(0 1 1000) +R_r1 vsig vout 1600 +C_c1 vout 0 100n +.tran 10u 10m UIC +.end`); + const t = vec('time'); + const v = vec('v(vout)'); + // After settling (>5 ms) measure peak amplitude + let peak = 0; + for (let i = 0; i < t.length; i++) { + if (t[i] < 5e-3) continue; + if (Math.abs(v[i]) > peak) peak = Math.abs(v[i]); + } + expect(peak).toBeGreaterThan(0.6); + expect(peak).toBeLessThan(0.85); + }); +}); + +describe('an-rc-high-pass', () => { + it('C=100n, R=1.6k: 1 kHz sine passes near −3 dB', { timeout: 60_000 }, async () => { + const { vec } = await runNetlist(`RC HPF +V_src vsig 0 SIN(0 1 1000) +C_c1 vsig vout 100n +R_r1 vout 0 1600 +.tran 10u 10m UIC +.end`); + const t = vec('time'); + const v = vec('v(vout)'); + let peak = 0; + for (let i = 0; i < t.length; i++) { + if (t[i] < 5e-3) continue; + if (Math.abs(v[i]) > peak) peak = Math.abs(v[i]); + } + expect(peak).toBeGreaterThan(0.6); + expect(peak).toBeLessThan(0.85); + }); +}); + +describe('an-rl-low-pass', () => { + it('L=10m, R=1k: corner near 16 kHz, 5 kHz passes mostly', { timeout: 60_000 }, async () => { + // fc = R/(2π·L) = 1000/(2π·10m) ≈ 15.9 kHz; at 5 kHz |H| ≈ 0.95 + const { vec } = await runNetlist(`RL LPF +V_src vsig 0 SIN(0 1 5000) +L_l1 vsig vout 10m +R_r1 vout 0 1000 +.tran 1u 4m UIC +.end`); + const t = vec('time'); + const v = vec('v(vout)'); + let peak = 0; + for (let i = 0; i < t.length; i++) { + if (t[i] < 2e-3) continue; + if (Math.abs(v[i]) > peak) peak = Math.abs(v[i]); + } + expect(peak).toBeGreaterThan(0.85); + expect(peak).toBeLessThan(1.05); + }); +}); + +describe('an-rlc-series-resonance', () => { + it('L=1m, C=1u: resonance ≈ 5.03 kHz, Vc peaks well above Vsrc', { timeout: 60_000 }, async () => { + // ω0 = 1/√(LC) → fr ≈ 5033 Hz. With Rs=10Ω, Q = 1/(R·√(C/L)) = 1/(10·√(1u/1m)) = 3.16 + // → Vc/Vs at resonance ≈ Q ≈ 3.16. Use AC analysis for cleanliness. + const { vec, dcValue } = await runNetlist(`RLC series resonance +V_src vsig 0 AC 1 0 +R_r1 vsig n1 10 +L_l1 n1 n2 1m +C_c1 n2 0 1u +.ac dec 100 1k 20k +.end`); + const f = vec('frequency'); + const vc = vec('v(n2)'); + let peakMag = 0, peakF = 0; + for (let i = 0; i < f.length; i++) { + const re = vc[i].real ?? vc[i]; + const im = vc[i].img ?? 0; + const mag = Math.sqrt(re * re + im * im); + if (mag > peakMag) { peakMag = mag; peakF = f[i].real ?? f[i]; } + } + expect(peakMag).toBeGreaterThan(2.5); + expect(peakF).toBeGreaterThan(4500); + expect(peakF).toBeLessThan(5500); + // sanity: dcValue won't matter for AC sweep but accessor should not throw + expect(typeof dcValue('v(n2)')).toBe('object'); // complex sample + }); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// DIODES (8–14) +// ════════════════════════════════════════════════════════════════════════════ + +describe('an-half-wave-rectifier', () => { + it('positive half-cycle reaches load, negative blocked', { timeout: 60_000 }, async () => { + const { vec } = await runNetlist(`Half-wave rectifier +V_src vsig 0 SIN(0 8 50) +D_d1 vsig vout D1N4007 +R_r1 vout 0 1000 +${D1N4007} +.tran 0.1m 60m UIC +.end`); + const t = vec('time'); + const v = vec('v(vout)'); + let posMax = -Infinity, negMin = Infinity; + for (let i = 0; i < t.length; i++) { + if (t[i] < 20e-3) continue; + if (v[i] > posMax) posMax = v[i]; + if (v[i] < negMin) negMin = v[i]; + } + expect(posMax).toBeGreaterThan(6.5); // 8 − Vf + expect(negMin).toBeGreaterThan(-0.2); // negative blocked + }); +}); + +describe('an-bridge-rectifier', () => { + it('full-wave: load voltage stays positive across both half-cycles', { timeout: 60_000 }, async () => { + // Bridge: vsig → D1.A; vsig → D3.C; 0 → D2.A; 0 → D4.C + // D1.C = D2.C = vplus; D3.A = D4.A = vminus; load between vplus−vminus + const { vec } = await runNetlist(`Bridge rectifier +V_src vsig 0 SIN(0 10 50) +D_d1 vsig vplus D1N4007 +D_d2 0 vplus D1N4007 +D_d3 vminus vsig D1N4007 +D_d4 vminus 0 D1N4007 +R_r1 vplus vminus 2200 +${D1N4007} +.tran 0.1m 60m UIC +.end`); + const t = vec('time'); + const vp = vec('v(vplus)'); + const vm = vec('v(vminus)'); + let minV = Infinity, maxV = -Infinity; + for (let i = 0; i < t.length; i++) { + if (t[i] < 20e-3) continue; + const v = vp[i] - vm[i]; + if (v < minV) minV = v; + if (v > maxV) maxV = v; + } + expect(minV).toBeGreaterThan(-0.5); // never strongly negative + expect(maxV).toBeGreaterThan(7.5); // 10 − 2·Vf + }); +}); + +describe('an-smoothed-rectifier', () => { + it('100uF cap smooths half-wave to near-DC ≈ Vpeak − Vf', { timeout: 60_000 }, async () => { + const { vec } = await runNetlist(`Smoothed rectifier +V_src vsig 0 SIN(0 10 50) +D_d1 vsig vout D1N4007 +C_c1 vout 0 100u +R_r1 vout 0 1000 +${D1N4007} +.tran 0.1m 200m UIC +.end`); + const t = vec('time'); + const v = vec('v(vout)'); + // After 5 cycles (>100ms) the cap should be charged near peak + let lastV = 0; + for (let i = 0; i < t.length; i++) if (t[i] > 150e-3) lastV = v[i]; + expect(lastV).toBeGreaterThan(7.0); + expect(lastV).toBeLessThan(10.0); + }); +}); + +describe('an-zener-regulator', () => { + it('1N4733 (Vz=5.1V) clamps load voltage near 5 V', { timeout: 30_000 }, async () => { + // r1 + zener (cathode = +rail, anode = GND) + load Rl in parallel with zener + const { dcValue } = await runNetlist(`Zener shunt regulator +V_src vsig 0 DC 12 +R_r1 vsig vout 220 +D_d1 0 vout D1N4733 +R_rl vout 0 2200 +${D1N4733} +.op +.end`); + const v = dcValue('v(vout)'); + expect(v).toBeGreaterThan(4.7); + expect(v).toBeLessThan(5.6); + }); +}); + +describe('an-diode-clipper', () => { + it('symmetric clipper limits |v(out)| to near one Vf', { timeout: 60_000 }, async () => { + // Velxio wires both diodes with their non-shunt terminals tied to GND, so + // the clipper symmetrically passes only ~±Vf around 0. + const { vec } = await runNetlist(`Diode clipper +V_src vsig 0 SIN(0 5 1000) +R_r1 vsig vout 1000 +D_d1 vout 0 D1N4007 +D_d2 0 vout D1N4007 +${D1N4007} +.tran 5u 5m UIC +.end`); + const t = vec('time'); + const v = vec('v(vout)'); + let absPeak = 0; + for (let i = 0; i < t.length; i++) { + if (t[i] < 1e-3) continue; + if (Math.abs(v[i]) > absPeak) absPeak = Math.abs(v[i]); + } + expect(absPeak).toBeLessThan(1.0); // clamped well below 5 V swing + }); +}); + +describe('an-diode-clamper', () => { + it('series cap + diode → output offset so negative peak ≈ −Vf', { timeout: 60_000 }, async () => { + const { vec } = await runNetlist(`Diode clamper +V_src vsig 0 SIN(0 5 1000) +C_c1 vsig vout 1u +D_d1 0 vout D1N4007 +R_rl vout 0 10000 +${D1N4007} +.tran 5u 30m UIC +.end`); + const t = vec('time'); + const v = vec('v(vout)'); + let mn = Infinity, mx = -Infinity; + for (let i = 0; i < t.length; i++) { + if (t[i] < 15e-3) continue; + if (v[i] < mn) mn = v[i]; + if (v[i] > mx) mx = v[i]; + } + // Negative peak should be clamped near −Vf (a few hundred mV below 0) + expect(mn).toBeGreaterThan(-1.5); + expect(mn).toBeLessThan(0.1); + // Positive peak should be roughly 2·Vpeak − Vf (well above the input peak) + expect(mx).toBeGreaterThan(6.0); + }); +}); + +describe('an-voltage-doubler', () => { + it('Villard/Greinacher: output charges toward +2·Vpeak after enough cycles', { timeout: 90_000 }, async () => { + // D1 anode=GND, cathode=n1 → clamps n1's negative excursion to ≈ −0.7. + // On the positive half-cycle C1 pushes n1 to ≈ +2·Vpeak, D2 (anode=n1, + // cathode=vout) transfers that charge into C2 → +Vout ≈ 2·Vpeak − 2·Vf. + const { vec } = await runNetlist(`Voltage doubler +V_src vsig 0 SIN(0 8 50) +C_c1 vsig n1 10u +D_d1 0 n1 D1N4007 +D_d2 n1 vout D1N4007 +C_c2 vout 0 100u +R_rl vout 0 10000 +${D1N4007} +.tran 0.1m 400m +.end`); + const t = vec('time'); + const v = vec('v(vout)'); + let lastV = 0; + for (let i = 0; i < t.length; i++) if (t[i] > 350e-3) lastV = v[i]; + // 2·8 − 2·0.7 ≈ 14.6, derated by load → expect ≥ 8 V and ≤ 17 V + expect(lastV).toBeGreaterThan(8.0); + expect(lastV).toBeLessThan(17); + }); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// BJT (15–20) +// ════════════════════════════════════════════════════════════════════════════ + +describe('an-bjt-common-emitter', () => { + it('CE amp biased: collector sits in mid-rail, not saturated', { timeout: 30_000 }, async () => { + const { dcValue } = await runNetlist(`CE amp +V_vcc vcc 0 DC 12 +R_rb1 vcc b 47000 +R_rb2 b 0 10000 +R_rc vcc c 4700 +R_re e 0 1000 +Q_q1 c b e Q2N2222 +${Q2N2222} +.op +.end`); + const vc = dcValue('v(c)'); + expect(vc).toBeGreaterThan(2.0); + expect(vc).toBeLessThan(11.0); + }); +}); + +describe('an-bjt-emitter-follower', () => { + it('emitter follower: V_e ≈ V_b − 0.7', { timeout: 30_000 }, async () => { + const { dcValue } = await runNetlist(`Emitter follower +V_vcc vcc 0 DC 12 +V_src vsig 0 DC 6 +R_rb vsig b 100000 +Q_q1 vcc b e Q2N2222 +R_re e 0 1000 +${Q2N2222} +.op +.end`); + const vb = dcValue('v(b)'); + const ve = dcValue('v(e)'); + expect(vb - ve).toBeGreaterThan(0.55); + expect(vb - ve).toBeLessThan(0.80); + }); +}); + +describe('an-bjt-switch', () => { + it('NPN switch ON: collector drops near saturation', { timeout: 30_000 }, async () => { + const { dcValue } = await runNetlist(`BJT switch +V_vcc vcc 0 DC 9 +V_src vsig 0 DC 4 +R_rb vsig b 4700 +R_rl vcc c 1000 +Q_q1 c b 0 Q2N2222 +${Q2N2222} +.op +.end`); + const vc = dcValue('v(c)'); + expect(vc).toBeLessThan(0.6); // saturated + }); +}); + +describe('an-darlington', () => { + it('Darlington pair saturates with tiny base drive', { timeout: 30_000 }, async () => { + const { dcValue } = await runNetlist(`Darlington +V_vcc vcc 0 DC 12 +V_src vsig 0 DC 2 +R_rb vsig b 10000 +R_rl vcc c 220 +Q_q1 c b e1 Q2N2222 +Q_q2 c e1 0 Q2N2222 +${Q2N2222} +.op +.end`); + expect(dcValue('v(c)')).toBeLessThan(2.5); + }); +}); + +describe('an-current-mirror', () => { + it('mirror: I(load) ≈ I(reference)', { timeout: 30_000 }, async () => { + const { vec } = await runNetlist(`NPN current mirror +V_vcc vcc 0 DC 12 +R_rref vcc cref 10000 +R_rload vcc cload 4700 +Q_q1 cref cref 0 Q2N2222 +Q_q2 cload cref 0 Q2N2222 +${Q2N2222} +.op +.end`); + // I_ref ≈ (12 − Vbe)/Rref ≈ 1.13 mA; mirror should match within ~20 % + const iRef = Math.abs(vec('i(v_vcc)')[0]); // total + expect(iRef).toBeGreaterThan(0.0015); // both branches active + expect(iRef).toBeLessThan(0.003); + }); +}); + +describe('an-bjt-diff-pair', () => { + it('diff pair: tail Vrtail ≈ V_inputs − Vbe, both Q ON', { timeout: 30_000 }, async () => { + const { dcValue } = await runNetlist(`Diff pair +V_vcc vcc 0 DC 12 +V_in1 in1 0 DC 2.5 +V_in2 in2 0 DC 2.5 +R_rc1 vcc c1 4700 +R_rc2 vcc c2 4700 +Q_q1 c1 in1 etail Q2N2222 +Q_q2 c2 in2 etail Q2N2222 +R_rt etail 0 4700 +${Q2N2222} +.op +.end`); + const vt = dcValue('v(etail)'); + const vc1 = dcValue('v(c1)'); + const vc2 = dcValue('v(c2)'); + expect(vt).toBeGreaterThan(1.5); // around V_in − 0.7 + expect(vt).toBeLessThan(2.0); + // With balanced inputs, Vc1 ≈ Vc2 (within 0.1 V) + expect(Math.abs(vc1 - vc2)).toBeLessThan(0.2); + // Both transistors active (each pulls down RC by some I·R) + expect(vc1).toBeLessThan(11.5); + }); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// MOSFET (21–23) +// ════════════════════════════════════════════════════════════════════════════ + +describe('an-mosfet-switch', () => { + it('2N7000 ON @ Vgs=4V drives drain low', { timeout: 30_000 }, async () => { + // Use the real W/L the mapper emits: L=2u W=200u + const { dcValue } = await runNetlist(`MOSFET low-side +V_vcc vcc 0 DC 12 +V_src vsig 0 DC 4 +R_rg vsig g 100 +R_rl vcc d 470 +M_m1 d g 0 0 M2N7000 L=2u W=200u +R_rgp g 0 100000 +${M2N7000} +.op +.end`); + // With W/L=100, Kp·W/L=5m, Vgst=2.4 → Id_sat ≈ 14.4 mA → Vd ≈ 12 − 6.7 ≈ 5.3 V + expect(dcValue('v(d)')).toBeLessThan(8); + }); +}); + +describe('an-mosfet-common-source', () => { + it('common-source bias: drain in mid-range (not rail-pinned)', { timeout: 30_000 }, async () => { + const { dcValue } = await runNetlist(`Common-source amp +V_vcc vcc 0 DC 12 +R_rg1 vcc g 1000000 +R_rg2 g 0 470000 +R_rd vcc d 4700 +R_rs s 0 1000 +M_m1 d g s 0 M2N7000 L=2u W=200u +${M2N7000} +.op +.end`); + const vd = dcValue('v(d)'); + expect(vd).toBeGreaterThan(0.5); + expect(vd).toBeLessThan(11.9); + }); +}); + +describe('an-mosfet-pmos-highside', () => { + it('PMOS ON when gate pulled low: drain near Vcc', { timeout: 30_000 }, async () => { + const { dcValue } = await runNetlist(`PMOS highside ON +V_vcc vcc 0 DC 12 +V_ctrl ctrl 0 DC 0 +R_rg ctrl g 1000 +M_m1 d g vcc vcc MIRF9540 L=2u W=2m +R_rl d 0 220 +${MIRF9540} +.op +.end`); + // Vgs = 0 − 12 = −12, |Vgs| − |Vto| = 9, fully on → drain ≈ Vcc minus small Rds·I drop + expect(dcValue('v(d)')).toBeGreaterThan(10); + }); + + it('PMOS OFF when gate ≈ Vcc: drain near 0', { timeout: 30_000 }, async () => { + const { dcValue } = await runNetlist(`PMOS highside OFF +V_vcc vcc 0 DC 12 +V_ctrl ctrl 0 DC 12 +R_rg ctrl g 1000 +M_m1 d g vcc vcc MIRF9540 L=2u W=2m +R_rl d 0 220 +${MIRF9540} +.op +.end`); + expect(dcValue('v(d)')).toBeLessThan(0.5); + }); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// OP-AMP (24–30) — LM358 behavioral macro from componentToSpice.ts +// ════════════════════════════════════════════════════════════════════════════ + +// Use the exact same macro shape NetlistBuilder emits for LM358 (vcc=5). +// V_out = max(0.05, min(Vcc-1.5, A·(V+−V−))) +const LM358 = (id, ip, in_, out, vcc = 5) => + `R_${id}_inp ${ip} 0 10Meg +R_${id}_inn ${in_} 0 10Meg +B_${id} ${out} 0 V = max(0.05, min(${vcc - 1.5}, 1e5*(V(${ip})-V(${in_})))) +R_${id}_out ${out} 0 1Meg`; + +describe('an-opamp-inverting', () => { + it('gain = -10 around Vref: Vout = Vref − 10·(Vin − Vref)', { timeout: 30_000 }, async () => { + // Vin = 2.7 (200 mV above Vref=2.5), expect Vout ≈ 2.5 − 2 = 0.5 (clamped lo=0.05) + const { dcValue } = await runNetlist(`Inverting amp +V_src vsig 0 DC 2.7 +V_vref vref 0 DC 2.5 +R_rin vsig n 1000 +R_rf n out 10000 +${LM358('u1', 'vref', 'n', 'out', 5)} +.op +.end`); + expect(dcValue('v(out)')).toBeCloseTo(0.5, 1); + }); +}); + +describe('an-opamp-non-inverting', () => { + it('gain = +11: Vin=0.2 → Vout = 2.2', { timeout: 30_000 }, async () => { + const { dcValue } = await runNetlist(`Non-inverting amp +V_src vsig 0 DC 0.2 +R_rf out n 10000 +R_rg n 0 1000 +${LM358('u1', 'vsig', 'n', 'out', 5)} +.op +.end`); + expect(dcValue('v(out)')).toBeCloseTo(2.2, 1); + }); +}); + +describe('an-opamp-follower', () => { + it('unity-gain buffer: Vout tracks Vin (within rails)', { timeout: 30_000 }, async () => { + for (const vin of [0.5, 1.5, 3.0]) { + const { dcValue } = await runNetlist(`Voltage follower vin=${vin} +V_src vsig 0 DC ${vin} +${LM358('u1', 'vsig', 'out', 'out', 5)} +R_rl out 0 1000 +.op +.end`); + expect(dcValue('v(out)')).toBeCloseTo(vin, 1); + } + }); +}); + +describe('an-opamp-summing', () => { + it('summing junction: Vout = Vref − Rf·(ΔV1/R1 + ΔV2/R2)', { timeout: 30_000 }, async () => { + // V1=1, V2=2, Vref=2.5, all R=10k → Vout = 2.5 − ((1-2.5) + (2-2.5)) = 2.5 + 2 = 4.5 + // Use Vcc=12 so vHi=10.5 doesn't clamp the result. + const { dcValue } = await runNetlist(`Summing amp +V_v1 v1 0 DC 1 +V_v2 v2 0 DC 2 +V_vref vref 0 DC 2.5 +R_r1 v1 n 10000 +R_r2 v2 n 10000 +R_rf n out 10000 +${LM358('u1', 'vref', 'n', 'out', 12)} +.op +.end`); + expect(dcValue('v(out)')).toBeCloseTo(4.5, 1); + }); +}); + +describe('an-opamp-integrator', () => { + it('integrator with DC step input drifts toward a rail', { timeout: 60_000 }, async () => { + // Use .ic for cap initial condition (the inline IC=… on a C card needs UIC, + // and UIC silently disabled the .tran sweep in our singleton ngspice). + const { vec } = await runNetlist(`Integrator step +V_src vsig 0 DC 3.5 +V_vref vref 0 DC 2.5 +R_rin vsig n 10000 +C_cf n out 100n +${LM358('u1', 'vref', 'n', 'out', 5)} +.ic v(out)=2.5 +.tran 100u 50m +.end`); + const t = vec('time'); + const v = vec('v(out)'); + let lastV = 0; + for (let i = 0; i < t.length; i++) if (t[i] > 30e-3) lastV = v[i]; + // Vin > Vref → output ramps DOWN (clamped at 0.05) due to inversion + expect(lastV).toBeLessThan(1.0); + }); +}); + +describe('an-opamp-comparator', () => { + it('open-loop comparator: V+ > V- → high rail, < → low rail', { timeout: 30_000 }, async () => { + const high = await runNetlist(`Comparator HIGH +V_src vsig 0 DC 3.0 +V_vref vref 0 DC 2.5 +${LM358('u1', 'vsig', 'vref', 'out', 5)} +R_rl out 0 10000 +.op +.end`); + expect(high.dcValue('v(out)')).toBeGreaterThan(3.4); + + const low = await runNetlist(`Comparator LOW +V_src vsig 0 DC 2.0 +V_vref vref 0 DC 2.5 +${LM358('u1', 'vsig', 'vref', 'out', 5)} +R_rl out 0 10000 +.op +.end`); + expect(low.dcValue('v(out)')).toBeLessThan(0.2); + }); +}); + +describe('an-schmitt-trigger', () => { + // Non-inverting Schmitt is bistable around the trip points so a bare .op + // can settle into either rail. Drive the input with a ramp and inspect the + // output at the ends — that exercises the actual hysteresis. + it('non-inverting Schmitt flips between rails as input crosses thresholds', { timeout: 60_000 }, async () => { + const { vec } = await runNetlist(`Schmitt sweep +V_src vsig 0 PWL(0 0 20m 0 60m 5 100m 5 140m 0 180m 0) +V_vref vref 0 DC 2.5 +R_r1 vsig p 10000 +R_r2 p out 10000 +${LM358('u1', 'p', 'vref', 'out', 5)} +.tran 0.5m 180m +.end`); + const t = vec('time'); + const v = vec('v(out)'); + let vMid = 0, vEnd = 0; + for (let i = 0; i < t.length; i++) { + if (t[i] >= 80e-3 && t[i] <= 90e-3) vMid = v[i]; // input held at 5 V + if (t[i] >= 170e-3) vEnd = v[i]; // input back at 0 V + } + expect(vMid).toBeGreaterThan(3.0); // saturated HIGH at high input + expect(vEnd).toBeLessThan(0.3); // saturated LOW after returning + }); +});