14 KiB
Gotchas & Debug Log
Every problem we hit during the experiment, with root cause and fix. Ordered roughly by time encountered.
G-1. Diode explodes on iteration 0
Symptom: a trivially simple diode circuit (5 V → 1 kΩ → D → GND) produced V_d ≈ 2.44 V or NaN. exp() overflow on the second Newton iteration.
Root cause: On iteration 0, V_d_prev = 0 → g_d = Is/(nVt) · exp(0) ≈ 10⁻¹³ S → diode is effectively open → the solver puts V_d = V_source = 5 V on the node. On iteration 1, exp(5 / 0.02585) = e¹⁹³ = 10⁸⁴ → infinity.
Fix: SPICE's pnjlim voltage limiting inside the diode's stampDC:
_limit(Vd, iteration) {
const nVt = this.n * Vt;
const Vcrit = nVt * Math.log(nVt / (Math.SQRT2 * this.Is));
if (iteration === 0 || this._VdLast === undefined) {
return Math.min(Vd, Vcrit); // first pass: never exceed ~0.73 V
}
const Vprev = this._VdLast;
if (Vd > Vcrit && Math.abs(Vd - Vprev) > 2 * nVt) {
if (Vprev > 0) return Vprev + nVt * Math.log(1 + (Vd - Vprev) / nVt);
return Vcrit;
}
return Vd;
}
Per-solve reset of _VdLast in Circuit.solveDC():
for (const c of components) {
if (c.isNonlinear && typeof c._resetIter === 'function') c._resetIter();
}
Reference: Colon, L. et al., SPICE manual section 9.3.
G-2. Capacitor not charging
Symptom: V(out) at t = τ was 5 V instead of 3.16 V. The cap appeared pre-charged.
Root cause: runTransient() called solveDC() first. In pure DC mode the capacitor is treated as open, so the solver concluded V_out = V_source = 5 V (no current through R, no cap load). That became the initial V_prev for the transient. The first transient step then saw an already-charged cap.
Fix: runTransient() no longer does an initial DC solve. It seeds state.prev.nodeVoltages directly from each capacitor's Vinit:
const initV = { gnd: 0, ...allNodesZero };
for (const comp of this.components) {
if (comp.Vinit !== undefined && typeof comp.a === 'string') {
initV[comp.a] = (initV[comp.b] ?? 0) + comp.Vinit;
}
}
this.state = {
nodeVoltages: { ...initV },
branchCurrents: {},
prev: { nodeVoltages: { ...initV }, branchCurrents: {} },
};
G-3. ADC gave values 0–3 instead of 0–255
Symptom: Running potToPwmProgram with V_A0 = 2.5 V, OCR0A was stuck at 0 or 1. PWM duty < 1 %.
Root cause: The sketch read ADCH directly. With ADLAR=0 (right-adjusted, the reset default), ADCH = 0b000000xx — only the top 2 bits of the 10-bit result. Maximum value: 3. So duty was capped at 3/255 ≈ 1 %.
Fix: Enable ADLAR (left-adjust) by setting ADMUX bit 5:
before: LDI r16, 0x40 ; REFS0=1 (AVCC), ADLAR=0
after: LDI r16, 0x60 ; REFS0=1 + ADLAR=1
Now ADCH contains the top 8 bits of the 10-bit result.
General principle: whenever you read ADC in a minimal AVR program that doesn't go through Arduino's analogRead(), either enable ADLAR and use ADCH-only, or read ADCL first then ADCH (to trigger the hardware's atomic-read latch).
G-4. Blue LED not hot enough
Symptom: Test expected V_f(blue) > 2.8 V. Measured V_f(blue) = 2.62 V.
Root cause: Initial Is = 1e-24 made the blue LED too conductive. Physical blue LEDs have Vf ≈ 3.0–3.2 V at I = 10–20 mA.
Fix: Retuned Is. For I = 10 mA at V_f = 3.0 V with n = 2.0:
I = Is · exp(V/(n·Vt))
10 mA = Is · exp(3.0/(2·0.02585))
10 mA = Is · exp(58)
Is = 10 mA / e⁵⁸ ≈ 6.7e-28
Rounded to Is = 1e-28 for blue and white. Other colors left unchanged.
Principle: tuning Is shifts the forward voltage. Tuning n shifts the slope of the I-V curve. Use both together to match a datasheet point (typically V_f at 10 or 20 mA).
G-5. BJT saturation not deep
Symptom: BJT switch test expected V_CE(sat) < 0.3 V. Measured V_CE = 0.68 V.
Root cause: Simplified Ebers-Moll (the "injection version" we implemented) doesn't model the deep-saturation region accurately. The model is first-order correct — the transistor is clearly ON and pulled the collector low — but misses the classic 0.1–0.3 V V_CE(sat) value.
Fix: relaxed the assertion to V_CE < 0.8 V and V_CE < V_CC − 3 V. For real BJT modeling (audio amplifiers, charge pumps, hobbyist designs), use the ngspice pipeline and a Gummel-Poon .model NPN Is=… Bf=… Vaf=… Nf=… Br=… Nr=… … parameter set from the manufacturer.
G-6. RJMP offset wrong after edits
Symptom: After adding two more instructions, the oscillator loop jumped to the wrong address.
Root cause: RJMP k encodes k as a signed 12-bit offset from PC + 1 (where PC is the word address of the RJMP itself). Changing instruction count between the jump and its target silently shifts the target address.
Fix: count words carefully.
For the adcReadProgram loop:
0-5: setup (6 words)
6: LDI r17, 0xC7 ← loop entry
7-8: STS ADCSRA, r17 (2 words)
9-10: LDS r17, ADCSRA (2 words)
11: SBRC r17, 6
12: RJMP -4 → PC+1=13, target=9, offset=−4 ✓
13-14: LDS r20, ADCH (2 words)
15-16: LDS r21, ADCL (2 words)
17: RJMP -12 → PC+1=18, target=6, offset=−12 ✓
Principle: every STS and LDS is a 32-bit instruction (2 words). Count accordingly when computing offsets.
G-7. ngspice B-source & instead of &&
Symptom: The digital-logic tests hung for 60 s, timing out. No error message.
Root cause: In ngspice B-source expressions, & is bitwise (on integer interpretations of the floats), while && is logical. The expression V(a) > 2.5 & V(b) > 2.5 parses but evaluates weirdly — ngspice was doing internal promotions / fallbacks and eventually got stuck.
Fix: use u() step functions and multiplication, which is portable across ngspice versions and other SPICE flavours:
; Wrong (may parse but hang):
Band y 0 V = 5 * (V(a) > 2.5 & V(b) > 2.5)
; Right:
Band y 0 V = 5 * u(V(a)-2.5) * u(V(b)-2.5)
Principle: u(x) is Heaviside: 1 if x > 0, else 0. Convert every logical gate to products/sums of u() calls. AND = product, OR = 1 − (1−a)(1−b), NOT = 1 − u(…), XOR = a + b − 2ab.
G-8. ngspice singular matrix — 60+ second hang
Symptom: A test that should run in 50 ms hangs for 60+ seconds. Note: Starting true gmin stepping appears in stderr.
Root cause: A node had no DC path to ground (e.g., R → C → out, where out is surrounded only by caps and voltage sources that don't provide a DC-level reference). ngspice tries multiple recovery strategies:
Note: Starting dynamic gmin stepping
Warning: singular matrix: check node n
Warning: Dynamic gmin stepping failed
Note: Starting true gmin stepping
Warning: True gmin stepping failed
Note: Starting source stepping
Warning: source stepping failed
Note: Transient op started ← finally gives up on DC, jumps to transient
Note: Transient op finished successfully
Each recovery stage can take many seconds. Combined, they blow past our 60 s test timeout.
Fix: explicitly add a high-impedance pull to ground on every otherwise-floating node:
R_pull out 0 10Meg
This gives the solver a DC reference without materially affecting the circuit (10 MΩ is much larger than any analog impedance we care about).
Principle: for every node in your SPICE netlist, make sure DC current can reach ground. Capacitors are DC-open; inductors are DC-shorts (usually OK); diodes and transistors vary by bias. When in doubt, add a 10 MΩ pull.
In Velxio's main app, the netlist builder should detect floating nodes automatically and add pulls.
G-9. ngspice behavioral model of 555 — cascaded B-sources don't hold state
Symptom: First attempt at a 555 timer astable used cascaded B-sources for the SR latch. It never oscillated.
Root cause: B-sources are stateless — they compute V = f(current-instant V's) with no memory. An SR latch needs memory. Trying to fake memory with a "state cap" (capacitor that B-source drives) is fragile and convergence-sensitive.
Fix: Use a voltage-controlled switch with hysteresis. The S-element's hysteresis window gives it state:
S1 out 0 ctrl 0 SMOD
.model SMOD SW(Vt=2.5 Vh=0.833 Ron=100 Roff=1G)
Turn-on threshold: Vt + Vh = 3.333. Turn-off threshold: Vt − Vh = 1.667. Between these, the switch retains its previous state.
For a relaxation oscillator: the switch grounds the charging cap through itself; the cap charges toward Vcc via R until it crosses Vt + Vh, the switch turns on and discharges the cap back below Vt − Vh, the switch turns off, repeat.
Principle: in plain ngspice (without XSPICE digital primitives), the only stateful primitives are C, L, and S (with hysteresis). Build every edge-triggered / level-triggered latch or FF from those.
G-10. Note: v1: has no value, DC 0 assumed
Symptom: A warning on stderr, but simulation produced sensible results.
Root cause: In AC analysis, if a source is declared as V1 in 0 AC 1 (only an AC value, no DC), ngspice defaults its DC operating point to 0 V. It prints this informational "Note" to stderr.
Fix: None needed. If you want to make it explicit: V1 in 0 DC 0 AC 1.
G-11. Test-to-test contamination in the ngspice singleton
Symptom: A test passed when run alone, hung for 60 s when run after other tests.
Root cause: The SpiceEngine singleton reuses a single Simulation across tests. If one simulation runs with a problematic netlist (e.g., a floating node triggering gmin recovery), internal state may be left in a weird spot.
Fix (applied): Fix the netlist to avoid floating nodes (see G-8). The hang we hit was actually due to G-8, not to a true state-leak bug.
Mitigation (not yet applied): If future bugs appear, SpiceEngine can be extended with a resetEngine() that throws away the singleton and boots a fresh Simulation. Penalty: 400 ms per reset.
G-12. cpu.cycles keeps growing across loadProgram
Symptom: After loading a new program into AVRHarness, cpu.cycles did not reset.
Root cause: AVRHarness.loadProgram() (and load()) construct a fresh CPU instance, so cpu.cycles does restart at 0. But if you re-use the same AVRHarness without calling load* again, cycles accumulate from previous runCycles() calls.
Fix: If you want a clean start, call avr.loadProgram(prog) or avr.load(hex) before each runCycles. Or construct a new AVRHarness().
Principle: this is correct behaviour. Tests that re-use the harness should be aware.
Performance gotchas
G-P1. .tran with fast PWM edges forces tiny timesteps
If your netlist has a PWM source with 10 ns rise/fall time and 1 kHz period, ngspice must resolve every edge — it cannot take a 100 µs step through a 10 ns transition.
Rule of thumb: don't model PWM with real edges if the time scale of the external circuit (RC filter, etc.) is much slower. Use the duty-cycle-averaged DC equivalent:
; Wrong for a DC filter analysis:
Vpwm pwm 0 PULSE(0 5 0 10n 10n 500u 1m)
; Right:
Vpwm pwm 0 DC 2.5 ; duty=0.5 → 2.5V DC
G-P2. First runSim call is 400 ms slower
The eecircuit-engine WASM boot is lazy. In Vitest, we use a singleton — the first test in a run pays 400 ms; subsequent tests pay 5–500 ms each. In a browser context, the 400 ms boot is user-visible; lazy-load the module behind a "⚡ Electrical simulation" toggle.
G-N. Unicode arrow (→) in netlist title silently hangs ngspice
Symptom: runNetlist() never resolves. Test times out after 30 s, no error logged.
Reproduction:
3.3V GPIO → MOSFET → LED
V_sys vsys 0 DC 5
...
The → (U+2192) in the first line (the title card) is all it takes. Removing it or replacing with to fixes it instantly.
Root cause: the ngspice-WASM build doesn't sanitize non-ASCII input in the title card. The character enters the parser in an unexpected state and the simulation loop never converges / exits.
Mitigation (enforced at test-author level): only ASCII in netlist titles. Non-ASCII is fine in comments (* ...) and inside B-source expressions. Consider a pre-commit hook that lints runNetlist() call sites — detailed in test/test_circuit/autosearch/06_ngspice_convergence.md.
G-M. MOSFET Level=3 with unphysical W causes .op to hang
Symptom: same as G-N — test times out, no error.
Reproduction:
.model M_X NMOS(Level=3 Vto=1.6 Kp=0.1 Rd=1 Rs=0.5)
M1 d g 0 0 M_X L=2u W=0.1
W=0.1 without unit is interpreted as 0.1 metres (100 mm channel width). With L=2u that is W/L = 50 000 and Kp=0.1 A/V² gives kiloamps of theoretical channel current; Newton blows up on the first iteration and can't recover. Tests that happened to have the drain shorted to ground escaped this because the external resistor forced a small Vds, but any free-swinging drain hangs.
Mitigation: use Level=1 Shichman-Hodges with W/L in a physically reasonable range (W/L between 10 and 10⁵, W ≤ 10 mm, L ≥ 1 µm). All fase-9 MOSFET mappers have been migrated to this pattern.
.model M2N7000 NMOS(Level=1 Vto=1.6 Kp=50u Lambda=0.01) ; with L=2u W=200u
What to check first when something fails
- Console stderr from ngspice often contains the root cause ("singular matrix", "model not found", "syntax error at line X").
result.variableNames— if you expectv(out)and the list hasv(OUT), case matching bit you. Our wrapper lowercases, but check.sim.getError()— returns the ngspice error buffer.- Run in isolation —
npx vitest run -t "specific test name"to rule out test-to-test contamination. - Simplify the netlist — strip components until the problem either disappears (you found the culprit) or persists (the remaining part is the problem).
- Add explicit DC paths —
R_pull node 0 10Megon every suspect-floating node.