From 08c9459c71c4a9ee6fc5f0bf12d9714dd9e5d8e1 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Mon, 17 Aug 2026 15:21:24 +0200 Subject: [PATCH] fix(examples): the common-emitter amplifier never left cutoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit analogRead printed a flat 1021-1023 on every loop — the collector pinned at Vcc. Measured in the running app: base 0.481 V emitter 0.001 V collector 4.994 V 0.481 V is below the ~0.65 V a 2N2222 needs, so the transistor never conducted and the "amplifier" was an open switch. The bias divider was fine on paper (47k/10k -> 0.88 V). What broke it was the 10k "coupling resistor" from the signal generator, whose own comment claimed it let the generator "wiggle the biased base without DC-shifting it". It does the opposite: a generator with 0 V offset is ground at DC, so that resistor sits in parallel with Rb2 — 10k || 10k = 5k -> Vb = 5 * 5/(47+5) = 0.481 V which is exactly the measured value. Replace it with a 1 uF coupling capacitor (159 ohm at the generator's 1 kHz: passes the signal, blocks the DC that was wrecking the bias), and take Rb2 to 15k so the collector sits near mid-rail — the operating point the sketch's own comment promises and the only one with room to swing both ways. The netlist snapshot could never have caught this: the text was exactly what the author intended. So the new test asserts the PHYSICS — bias in the active region, collector off both rails, and a collector that actually swings — resolving node names through the builder's pin map rather than hardcoding `n2`, and adding the 5 V rail the board supplies in the app. --- .../examples-netlist-snapshot.test.ts.snap | 4 +- .../bjt-common-emitter-behaviour.test.ts | 95 +++++++++++++++++++ frontend/src/data/examples-circuits.ts | 21 ++-- 3 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 frontend/src/__tests__/bjt-common-emitter-behaviour.test.ts diff --git a/frontend/src/__tests__/__snapshots__/examples-netlist-snapshot.test.ts.snap b/frontend/src/__tests__/__snapshots__/examples-netlist-snapshot.test.ts.snap index ebbbfef1..ea795db6 100644 --- a/frontend/src/__tests__/__snapshots__/examples-netlist-snapshot.test.ts.snap +++ b/frontend/src/__tests__/__snapshots__/examples-netlist-snapshot.test.ts.snap @@ -1494,12 +1494,12 @@ R_autopull_n8 n8 0 100Meg exports[`netlist snapshot — circuits (41 examples) > bjt-common-emitter 1`] = ` "* Velxio circuit R_rb1 n0 n2 47000 -R_rb2 n2 0 10000 +R_rb2 n2 0 15000 R_rc n0 n3 4700 R_re n1 0 1000 Q_q1 n3 n2 n1 Q2N2222 V_sg1 n4 0 SIN(0 0.05 1000) -R_rin n4 n2 10000 +C_cin n4 n2 0.000001 IC=0 .model Q2N2222 NPN(IS=1E-14 VAF=100 BF=200 IKF=0.3 XTB=1.5 BR=3 CJC=8E-12 CJE=25E-12 TR=100E-9 TF=400E-12 ITF=1 VTF=2 XTF=3 RB=10 RC=.3 RE=.2) .tran 5.000e-5 5.000e-3 .end" diff --git a/frontend/src/__tests__/bjt-common-emitter-behaviour.test.ts b/frontend/src/__tests__/bjt-common-emitter-behaviour.test.ts new file mode 100644 index 00000000..3cb3fdbc --- /dev/null +++ b/frontend/src/__tests__/bjt-common-emitter-behaviour.test.ts @@ -0,0 +1,95 @@ +/** + * The common-emitter example must actually AMPLIFY. + * + * The netlist snapshot froze this circuit's text for a long time while the + * circuit did nothing: `analogRead(A0)` printed a flat 1021-1023 on every loop, + * which is the collector pinned at Vcc. Measured in the running app, the bias + * point was base 0.481 V, emitter 0.001 V, collector 4.994 V — the transistor + * sat in cutoff, because the 10k "coupling resistor" from the signal generator + * is in parallel with Rb2 at DC (a generator with 0 V offset is ground for DC): + * + * 10k || 10k = 5k -> Vb = 5 * 5/(47+5) = 0.481 V (needs ~0.65 V) + * + * A snapshot cannot catch that: the text was exactly what the author intended. + * So assert the physics instead — bias in the active region, and an output that + * swings with gain. + */ +import { describe, expect, it } from 'vitest'; +import { buildNetlist } from '../simulation/spice/NetlistBuilder'; +import { exampleToBuildNetlistInput } from '../utils/exampleToBuildNetlistInput'; +import { exampleProjects } from '../data/examples'; +import { runNetlist } from './helpers/testSolver'; + +const EXAMPLE_ID = 'bjt-common-emitter'; + +/** The example's real netlist plus the pin->net map, board and all — the same + * path the app takes. Dropping the board would drop the 5 V rail with it and + * every node would read 0 V: a dead amplifier, but really a dead test. + * + * Node NAMES are resolved through the map rather than hardcoded: the builder + * numbers nets in traversal order, so `n2` is not stably "the base" and a + * hardcoded guess silently measures the wrong wire. */ +function rigFor(id: string) { + const ex = exampleProjects.find((e) => e.id === id); + if (!ex) throw new Error(`example ${id} not found`); + const built = buildNetlist(exampleToBuildNetlistInput(ex)); + const netOf = (pin: string): string => { + const n = built.pinNetMap.get(pin); + if (!n) throw new Error(`no net for ${pin} — wiring changed?`); + return n; + }; + // In the app the board supplies its own rails (collectBoardPinStates marks 5V + // as an output, so the builder emits a source for it). Building from the + // example alone leaves that node unpowered, and an unpowered amplifier reads + // 0 V everywhere — which would make this test pass or fail for the wrong + // reason. Add the rail the board would provide. + const vccNet = netOf('arduino-uno:5V'); + const netlist = built.netlist.replace( + /^\.tran/m, + `V_testrail ${vccNet} 0 DC 5\n.tran`, + ); + return { + netlist, + base: `v(${netOf('q1:B')})`, + emitter: `v(${netOf('q1:E')})`, + collector: `v(${netOf('q1:C')})`, + }; +} + +/** Numeric magnitude of a possibly-complex vector sample. */ +const mag = (v: number | { real: number; imag: number }): number => + typeof v === 'number' ? v : Math.hypot(v.real, v.imag); + +describe('bjt-common-emitter behaviour', () => { + it('biases the transistor into the active region', async () => { + const rig = rigFor(EXAMPLE_ID); + const result = await runNetlist(rig.netlist); + const base = mag(result.vAtLast(rig.base)); + const emitter = mag(result.vAtLast(rig.emitter)); + + expect(base).toBeGreaterThan(0.65); // above Vbe(on) — otherwise: cutoff + expect(emitter).toBeGreaterThan(0.1); // current is actually flowing + }); + + it('does not pin the collector to either rail', async () => { + const rig = rigFor(EXAMPLE_ID); + const result = await runNetlist(rig.netlist); + const collector = mag(result.vAtLast(rig.collector)); + // The sketch's own comment promises a mid-rail bias. Anything within a few + // hundred mV of a rail has no room to swing and reads as a flat ADC line. + expect(collector).toBeGreaterThan(1.0); + expect(collector).toBeLessThan(4.5); + }); + + it('swings the collector — the output is an amplified signal, not a flat line', async () => { + const rig = rigFor(EXAMPLE_ID); + const result = await runNetlist(rig.netlist); + const samples = result.vec(rig.collector).map(mag).filter(Number.isFinite); + expect(samples.length).toBeGreaterThan(10); + const swing = Math.max(...samples) - Math.min(...samples); + // Input is 0.05 V peak (0.1 Vpp) and the stage's gain is -Rc/Re = -4.7, so + // a working amplifier moves the collector by a good fraction of a volt. + // A flat line — the bug — measures ~0. + expect(swing).toBeGreaterThan(0.1); + }); +}); diff --git a/frontend/src/data/examples-circuits.ts b/frontend/src/data/examples-circuits.ts index 7ad39cc0..f851a43b 100644 --- a/frontend/src/data/examples-circuits.ts +++ b/frontend/src/data/examples-circuits.ts @@ -553,7 +553,10 @@ void loop() { components: [ UNO, { type: 'wokwi-resistor', id: 'rb1', x: 350, y: 60, properties: { value: '47000' } }, - { type: 'wokwi-resistor', id: 'rb2', x: 350, y: 180, properties: { value: '10000' } }, + // 47k/15k puts the base at 1.2 V -> Ve 0.51 V -> Ic 0.51 mA -> collector at + // ~2.6 V, i.e. the mid-rail bias the sketch comment promises, so the + // output can swing both ways instead of clipping against a rail. + { type: 'wokwi-resistor', id: 'rb2', x: 350, y: 180, properties: { value: '15000' } }, { type: 'wokwi-resistor', id: 'rc', x: 450, y: 60, properties: { value: '4700' } }, { type: 'wokwi-resistor', id: 're', x: 450, y: 280, properties: { value: '1000' } }, { type: 'wokwi-bjt-2n2222', id: 'q1', x: 450, y: 170, properties: {} }, @@ -564,9 +567,15 @@ void loop() { y: 140, properties: { waveform: 'sine', frequency: 1000, amplitude: 0.05, offset: 0 }, }, - // Small "coupling" resistor from SG to the base node so SG can wiggle - // the biased base without DC-shifting it. - { type: 'wokwi-resistor', id: 'rin', x: 300, y: 140, properties: { value: '10000' } }, + // Coupling CAPACITOR from the generator to the base. It has to be a cap: + // a resistor here sits in parallel with Rb2 for DC (the generator is 0 V + // at DC), which is what the 10k "coupling resistor" used to do — + // 10k || 10k = 5k dropped the base from 0.88 V to 0.48 V, below the + // ~0.65 V a 2N2222 needs, so the transistor sat in cutoff, the collector + // stayed pinned at Vcc and analogRead printed a flat 1021-1023 forever. + // 1 uF is 159 ohm at the generator's 1 kHz: it passes the signal and + // blocks the DC that was wrecking the bias. + { type: 'wokwi-capacitor', id: 'cin', x: 300, y: 140, properties: { value: '1u' } }, ], wires: [ // Bias divider: 5V → Rb1 → (base) → Rb2 → GND @@ -581,8 +590,8 @@ void loop() { w('w7', ['q1', 'E'], ['re', '1']), w('w8', ['re', '2'], ['arduino-uno', 'GND'], '#000000'), // AC input into base through coupling R - w('w9', ['sg1', 'SIG'], ['rin', '1']), - w('w10', ['rin', '2'], ['q1', 'B']), + w('w9', ['sg1', 'SIG'], ['cin', '1']), + w('w10', ['cin', '2'], ['q1', 'B']), w('w11', ['sg1', 'GND'], ['arduino-uno', 'GND'], '#000000'), // A0 probes the collector w('w12', ['q1', 'C'], ['arduino-uno', 'A0'], '#ffaa00'),