fix(examples): the common-emitter amplifier never left cutoff

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.
This commit is contained in:
David Montero Crespo 2026-08-17 15:21:24 +02:00
parent 5545089389
commit 08c9459c71
3 changed files with 112 additions and 8 deletions

View File

@ -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"

View File

@ -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);
});
});

View File

@ -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'),