velxio/docs/wiki/circuit-emulation-gotchas.md

488 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`:
```javascript
_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()`:
```javascript
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`:
```javascript
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 03 instead of 0255
**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.03.2 V` at `I = 1020 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.10.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 (1a)(1b)`, 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 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 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 5500 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:**
```spice
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`](../../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:**
```spice
.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.
```spice
.model M2N7000 NMOS(Level=1 Vto=1.6 Kp=50u Lambda=0.01) ; with L=2u W=200u
```
## G-R. 490 Hz PWM starves the SPICE solver via the store-change debounce
**Symptom (2026-04-20):** In the `mosfet-pwm-led` example, the LED does
nothing while the sketch runs (`analogWrite(9, 0..255..0)` ramp), then
**lights up the instant the user presses Stop**. Static circuits (no PWM)
simulate correctly. Affects every circuit where a SPICE-owned component is
wired directly to an MCU pin that the sketch drives with `analogWrite()`.
**Why "works on stop" was the decisive clue:** stopping the AVR is the only
event that halts the stream of pin-change callbacks. SPICE is clearly
*capable* of solving the circuit the solve just never runs while the
firing rate is high. That points straight at the scheduler's debounce.
**Root cause — a feedback loop between the digital side and the solver:**
1. `SimulatorCanvas`'s generic wire subscription calls
`pinManager.onPinChange(pin, …)` for every component wired directly to a
board pin. When the pin toggles, the callback calls
`updateComponentState(component.id, state)` unless the component has
`attachEvents` registered in `PartSimulationRegistry` (`hasSelfManagedVisuals`).
2. `updateComponentState` writes `properties.state` / `properties.value` on
the target component producing a **new `components` array** in the
Zustand store.
3. `wireElectricalSolver` subscribes to the store and calls `maybeSolve()`
whenever `state.components !== prev.components`.
4. `maybeSolve()` `triggerSolve()` `circuitScheduler.requestSolve()`
which **resets the 50 ms debounce timer** on every call.
5. Under AVR's hardware PWM on pin 9 (Timer1, 490 Hz), pin 9 toggles every
~2 ms. The 50 ms debounce is reset ~20× before it can ever expire, so
`drain()` never runs and ngspice is never invoked.
6. On Stop the toggles cease, 50 ms later the debounce finally fires, the
solve completes with the last-seen PWM duty, and the LED paints bright
which is exactly what the user reported seeing.
The periodic 200 ms solver timer in `subscribeToStore` is unable to rescue
this: it calls the same `maybeSolve` which passes through the same debounce
and is swamped by the pin-toggle stream.
**Minimal reproducer (in-browser):** any two-terminal SPICE-mapped
component whose only connection to the MCU is a single GPIO wire is enough.
MOSFET gate pin 9 is the canonical case, but a resistor from pin 3 to a
cap, a diode anode on pin 5, an opamp `IN+` on pin 6, a logic-gate input on
pin 11, all behaved identically in testing.
**Diagnostic that exonerated ngspice:** running `buildNetlist` + `runNetlist`
with a fixed `duty=1.0` inside a Vitest harness that mirrors
`CircuitScheduler.drain()` produced a correct branch current
(`v_led1_sense` = 6.8 mA at 5 V gate) in 380 ms. SPICE was fine the
pipeline upstream of the scheduler was broken.
[`frontend/src/__tests__/spice-mosfet-diag.test.ts`](../../frontend/src/__tests__/spice-mosfet-diag.test.ts)
is the permanent form of that probe.
**Fix — one generic rule in `SimulatorCanvas`:** treat every SPICE-owned
component as authoritative-to-SPICE. The legacy digital echo is skipped
for them so no pin toggle ever mutates `components`.
```typescript
// frontend/src/components/simulator/SimulatorCanvas.tsx
const logic = PartSimulationRegistry.get(component.metadataId);
const spiceOwned = isSpiceMapped(component.metadataId);
const hasSelfManagedVisuals = !!(logic && logic.attachEvents) || spiceOwned;
```
That single line protects every current and future mapper in
`componentToSpice.ts` (R/L/C, diodes, LEDs, BJTs, MOSFETs, op-amps,
regulators, optos, relays, L293D, logic gates, instruments, signal
generators, switches, pots, NTCs, photo-resistors, photodiodes, batteries).
SPICE samples the true pin state from `PinManager` at the 200 ms periodic
tick quasi-DC equivalent of the PWM, which is the correct simplification
for an operating-point solver.
**What was explicitly rejected:**
- *Register every active device with a no-op `attachEvents`.* It works but
flips `isInteractive` in `DynamicComponent` cursor becomes `pointer`
over MOSFETs, BJTs, and opamps misleading the user into thinking they
can click them. Using `isSpiceMapped(…)` at the one call site that cares
is the cleaner demarcation.
- *Lengthen the debounce until it's longer than the PWM period.* Any debounce
long enough to survive 490 Hz would also make wire edits feel sluggish.
The problem is that digital pin toggles were being treated as circuit
edits in the first place.
- *Dedup at the `triggerSolve` level based on JSON input.* Already in place
but it runs *after* `requestSolve`, so the debounce has already been
reset. Moving the dedup earlier doesn't help either, because the input
genuinely does change (`properties.state` flips) even when nothing that
SPICE cares about has.
**Unrelated cleanup done at the same time:** removed a leftover
`console.log('[WirePin] …')` inside the wire subscription loop that was
firing at the same rate as the pin subscriptions and filling the devtools
console.
**Fidelity note:** no fidelity was sacrificed. SPICE is still the single
source of truth for voltages and currents on every SPICE-mapped component.
Digital-only parts (pushbuttons, membrane keypads, DIP switches, slide
switches, I²C displays, servos, buzzers) continue through the unchanged
`attachEvents` path.
**Regression guard:**
[`frontend/src/__tests__/spice-mosfet-pwm.test.ts`](../../frontend/src/__tests__/spice-mosfet-pwm.test.ts)
asserts monotonic LED current across the ramp; the diag test above
asserts the scheduler-filtered keys still land in `branchCurrents`.
## G-S. SPICE-mapped components silently stop updating after `parts/*.ts` edits
Two distinct bugs, both surfaced while finishing the G-R rollout. Both have
the same shape: `buildNetlist` + `runNetlist` produce correct currents in a
Vitest harness, but the component in the browser never reflects them. Tests
green, UI wrong the most frustrating failure mode.
### G-S.1 — `require()` inside an `attachEvents` callback
**Symptom:** LED stays dark in the `mosfet-pwm-led` (and any) SPICE-driven
example even after the G-R fix lands. No console error. No thrown promise.
The `branchCurrents` store is populated correctly; the LED just never reads
from it.
**Root cause:** `BasicParts.ts` imported the Zustand store lazily from
inside the subscribe callback:
```typescript
attachEvents: (el, _sim, _getPin, componentId) => {
const update = () => {
const { useElectricalStore } = require('../../store/useElectricalStore'); // ← dies silently
const { branchCurrents } = useElectricalStore.getState();
};
const unsub = useElectricalStore.subscribe(update); // ← same problem
},
```
Vite's esbuild pipeline doesn't polyfill CommonJS `require` in browser code.
The call threw `ReferenceError: require is not defined` the first time
`update()` ran. Zustand's `subscribe` swallows exceptions from listener
callbacks (by design one broken listener can't kill the others), so the
error never made it to the console and the LED silently did nothing.
**Fix:** static ESM import at module top.
```typescript
import { useElectricalStore } from '../../store/useElectricalStore';
attachEvents: (el, _sim, _getPin, componentId) => {
const update = () => {
const { branchCurrents } = useElectricalStore.getState();
};
},
```
**Principle:** inside any file that ships to the browser, `require()` is a
footgun. If a bundler tolerates it in dev, production may not. And if the
call lives inside a subscribe/pin-change/event callback, the exception
won't even be visible. Prefer top-of-file `import` always.
### G-S.2 — Vite HMR keeps old `attachEvents` bound after `parts/*.ts` edits
**Symptom:** You edit `BasicParts.ts` (or any `parts/*.ts`), save, the HMR
banner flashes "updated" but the circuit behaviour in the browser is
unchanged. You doubt your own fix, add `console.log`s that never fire, and
eventually rewrite code that was already correct.
**Root cause:** `parts/index.ts` registers every part into
`PartSimulationRegistry` via module-load side effects. When HMR swaps a
`parts/*.ts` module, the *registry* sees the new `attachEvents`, but the
LED/MOSFET/etc. components that were rendered before the edit are still
holding references to the *old* `attachEvents` via the `unsub` closures
they set up in their mount effect. Until those components unmount and
remount, they keep running the stale callback.
The NPN-switch debug session burned ~90 min on this: all three Vitest
harnesses (`spice-npn-switch-diag`, `spice-npn-switch-integration`, and the
pre-existing `spice-mosfet-pwm`) proved the pipeline was correct end-to-end
while the browser kept showing the LED permanently lit.
**Fix:** full restart whenever you touch anything under
`frontend/src/simulation/parts/` or `frontend/src/simulation/spice/`:
```
Ctrl+C # kill dev server
npm run dev # fresh module graph
Ctrl+Shift+R (in browser) # discard old Zustand subscribers
```
**Mitigation (not applied):** we could add `import.meta.hot?.invalidate()`
at the bottom of every `parts/*.ts` to force a full page reload on edit.
Considered too disruptive for the file-frequency these get edited at
the restart rule is easier to remember once you've been bitten.
**Principle:** HMR is fine for React components and CSS. For code that
wires long-lived subscriptions inside a global registry, assume HMR is
lying to you and restart.
## What to check first when something fails
0. **If you just edited `parts/*.ts` or `spice/*.ts` and the browser looks wrong** restart the dev server before anything else (see G-S.2). Tests are the authoritative signal; the dev server is not.
1. **Console stderr** from ngspice often contains the root cause ("singular matrix", "model not found", "syntax error at line X").
2. **`result.variableNames`** if you expect `v(out)` and the list has `v(OUT)`, case matching bit you. Our wrapper lowercases, but check.
3. **`sim.getError()`** returns the ngspice error buffer.
4. **Run in isolation** `npx vitest run -t "specific test name"` to rule out test-to-test contamination.
5. **Simplify the netlist** strip components until the problem either disappears (you found the culprit) or persists (the remaining part is the problem).
6. **Add explicit DC paths** `R_pull node 0 10Meg` on every suspect-floating node.
7. **Mirror the browser path in a Vitest harness** if tests pass but UI fails, the bug is upstream of `buildNetlist` (subscription, import, HMR) not in SPICE. `spice-npn-switch-integration.test.ts` is the reference template for this.