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

10 KiB
Raw Blame History

ngspice-WASM Integration

Location: test/test_circuit/src/spice/

Why eecircuit-engine

When the user pushed back on "no quiero inventar nada raro", we surveyed the landscape:

Package Status 2026-04 Decision
eecircuit-engine 1.7.0 Active, published npm Chosen
tscircuit/ngspice GitHub only, not on npm Deferred (requires fork+publish)
ngspice.js Demo site, no npm API Declined
SpiceJS Web UI, no programmable API Declined
webspice on npm Unrelated (NASA observation geometry) Declined
vollgas Pure-digital logic-gate sim Declined

eecircuit-engine is ngspice compiled with Emscripten to WebAssembly + a thin TypeScript wrapper. It is published by eelab-dev, used as the engine behind eecircuit.com. MIT licensed, has TypeScript types.

Installation

cd test/test_circuit
npm install eecircuit-engine

Resulting size on disk:

node_modules/eecircuit-engine/
├── LICENSE
├── README.md
├── dist/
│   ├── eecircuit-engine.mjs     ← ESM entry
│   ├── eecircuit-engine.umd.js  ← UMD bundle
│   ├── main.d.ts                ← TypeScript types
│   └── (WASM binary embedded as base64 inside the JS bundle)
└── package.json

Total: 39 MB

Yes, 39 MB is large. In Velxio production this must be lazy-loaded: the user hits a " Electrical simulation" toggle, and only then is the bundle fetched. The size trade-off is documented in performance.

API surface (from dist/main.d.ts)

export declare class Simulation {
  start: () => Promise<void>;
  setNetList: (input: string) => void;
  runSim: () => Promise<ResultType>;
  getInfo: () => string;
  getInitInfo: () => string;
  getError: () => string[];
  isInitialized: () => boolean;
}

export declare type ResultType = {
  header: string;
  numVariables: number;
  variableNames: string[];
  numPoints: number;
  dataType: "real";
  data: RealDataType[];   // data[i].values: number[]
} | {
  header: string;
  numVariables: number;
  variableNames: string[];
  numPoints: number;
  dataType: "complex";
  data: ComplexDataType[]; // data[i].values: {real, img}[]
};

That's the entire public API. Everything else — analyses, models, components — is expressed through the netlist string.

The wrapper

src/spice/SpiceEngine.js wraps this with conveniences:

import { runNetlist, NL } from '../src/spice/SpiceEngine.js';

// Simple DC
const { dcValue } = await runNetlist(`VDIV
V1 vcc 0 DC 9
R1 vcc out 1k
R2 out 0 2k
.op
.end`);
console.log(dcValue('v(out)'));   // 6

// Transient: vec(name) returns array
const { vec } = await runNetlist(`
V1 in 0 PULSE(0 5 0 1n 1n 10m 20m)
R1 in out 10k
C1 out 0 100u IC=0
.tran 100u 30m
.end`);
const time = vec('time');
const vout = vec('v(out)');

// AC sweep: vec returns complex
const { vec: vecAC } = await runNetlist(`
V1 in 0 AC 1
R1 in out 1k
C1 out 0 1u
.ac dec 20 10 1Meg
.end`);
const freq = vecAC('frequency');         // [{real, img}] — img is 0 for freq
const vout = vecAC('v(out)');            // [{real, img}]
const mag_dB = vout.map(c => 20*Math.log10(Math.sqrt(c.real**2 + c.img**2)));

Key design decisions:

  • Singleton engine. Booting takes 400 ms; we boot once per test process.
  • findVar(name) accepts either "v(node)" or "node" — ngspice typically uses the former for voltages and time / frequency for the sweep variables.
  • Case-insensitive. SPICE is historically case-preserving-but-insensitive; ngspice tends to lowercase node names.
  • NL.pulse / NL.sin / NL.pwl helpers format the common source cards correctly.

Netlist syntax (ngspice)

This section summarizes the SPICE cards we validated. For the full reference, see the ngspice manual.

Passive

R1 node1 node2 1k        ; resistor in Ω (k=1e3, Meg=1e6, u=1e-6, n=1e-9, p=1e-12)
C1 node1 node2 100u IC=0 ; capacitor, optional initial condition
L1 node1 node2 10m       ; inductor
V1 plus  minus DC 5      ; voltage source
I1 from  to    DC 1m     ; current source

Sources

* Pulse: V1 P N PULSE(V_init V_final T_delay T_rise T_fall T_pulse_width T_period)
Vclk clk 0 PULSE(0 5 0 10n 10n 1u 2u)

* Sinusoidal: V1 P N SIN(V_offset V_amp freq)
Vosc osc 0 SIN(0 1 1k)

* Piecewise linear: V1 P N PWL(t0 v0 t1 v1 t2 v2 ...)
Vsig sig 0 PWL(0 0 1m 5 2m 5 3m 0)

* For AC analysis: add "AC amp" on the source card
V1 in 0 AC 1             ; 1V AC small-signal
V1 in 0 DC 0 AC 1        ; DC bias + AC probe

Active devices

* Diode
D1 anode cathode DMOD
.model DMOD D(Is=1e-14 N=1)
* ngspice ships common models; for 1N4148:
.model D1N4148 D(Is=2.52n N=1.752 Rs=0.568 Ibv=0.1u Bv=100)

* BJT (npn/pnp)
Q1 collector base emitter Q2N2222
.model Q2N2222 NPN(Is=1e-14 Bf=200 Vaf=75)

* MOSFET (L1 Shichman-Hodges)
M1 drain gate source bulk NMOS_L1 L=1u W=100u
.model NMOS_L1 NMOS(Level=1 Vto=1.0 Kp=50u Lambda=0.01)

Controlled sources (ideal op-amps etc.)

* Voltage-controlled voltage source (VCVS)
Eopa  out 0    inp_plus inp_minus  1e6    ; V_out = 1e6·(V(inp_plus) - V(inp_minus))
* Voltage-controlled current source (VCCS)
Gx    out 0    inp_plus inp_minus  1m     ; I_out = 1m·ΔV
* Current-controlled voltage source (CCVS): needs a voltage source to sense current
* H1 out 0 V_sense 100
* Current-controlled current source (CCCS): F1 out 0 V_sense 100

Behavioral (B-source) — the mixed-signal key

B sources compute a voltage (or current) from an arbitrary expression of node voltages and other quantities:

B1 out 0 V = 5 * u(V(a) - 2.5)                    ; simple inverter
B2 and 0 V = 5 * u(V(a)-2.5) * u(V(b)-2.5)        ; AND
B3 xor 0 V = 5 * (u(V(a)-2.5) + u(V(b)-2.5) - 2*u(V(a)-2.5)*u(V(b)-2.5))

Supported functions include u(x) (Heaviside step), sin, cos, exp, log, sqrt, abs, min, max, limit(x, lo, hi), ternary a?b:c — see the ngspice manual for the full list.

Important gotchas (documented separately in gotchas):

  • Use && / || for logical (not & / |, which are bitwise and behave differently in B-sources).
  • u(x-threshold) is the most portable way to get digital-like behaviour.

Voltage-controlled switches (stateful)

S1 out 0 ctrl 0 SMOD
.model SMOD SW(Vt=2.5 Vh=0.833 Ron=100 Roff=1G)

Vt is the threshold, Vh is the hysteresis half-width: switch turns ON when V(ctrl) > Vt+Vh, OFF when V(ctrl) < VtVh. Between the thresholds the switch retains its state. This is the only stateful element we found in the current eecircuit-engine build that does not require XSPICE. Essential for building relaxation oscillators / latches behaviorally.

Analyses

.op                         ; DC operating point
.tran tstep tstop           ; transient; e.g. .tran 10u 5m
.tran tstep tstop tstart    ; transient with start time
.tran tstep tstop tstart tmax  ; with max step
.ac DEC ppd fstart fstop    ; AC sweep (decade, points-per-decade)
.dc Vsrc vstart vstop vstep ; DC sweep
.noise v(n1,n2) Vsrc DEC ...; noise analysis (not validated here)

Every netlist must end with .end.

Result parsing

const result = await sim.runSim();
// result.dataType: "real" | "complex"
// result.variableNames: string[]     e.g. ["time", "v(in)", "v(out)"]
// result.data: { name, type, values }[]

For .op: numPoints=1, values[0] is the single operating-point value. For .tran: values is the time series, and variableNames[0] is usually "time". For .ac: dataType='complex', variableNames[0] is "frequency", and voltage values are {real, img}.

Our wrapper's dcValue(name) just returns vec(name)[0]; for .op that is the operating point.

Lessons learned (ngspice warnings)

ngspice emits informational and warning messages on stderr. We see them in test output:

Note: v1: has no value, DC 0 assumed           ; harmless — you declared V1 with only AC amp
Warning: singular matrix: check node n          ; FIX: add a pull to ground on the floating node
Note: Starting dynamic gmin stepping            ; recovery strategy kicking in
Warning: Dynamic gmin stepping failed           ; still singular
Note: Starting source stepping                  ; last-ditch recovery
Warning: source stepping failed                 ; now we're in trouble
Note: Transient op started                      ; gave up on DC, jumping to t=0
Note: Transient op finished successfully        ; got it eventually

The Warning: singular matrix path can cost 60+ seconds. Always ensure every node has a DC path to ground — add a R_pull node 0 1Meg if necessary (documented in gotchas).

Performance

Benchmark numbers on WSL2 / Node 22 / Core i7-12700:

Task Time
First-time engine boot ~400 ms
.op with 3 passive components 510 ms
.op with diode 2030 ms
.op with BJT 5080 ms
.tran 3m 1u (10 passive components) 100300 ms
.tran 30m 10u with switches (555-core) ~400 ms
.tran 40m 0.1m with 4 diodes (bridge rectifier) ~300 ms
.ac dec 30 10 1Meg (RC low-pass) ~10 ms

For the sandbox the engine is a singleton, so the 400 ms boot is paid only once per vitest process.

Code example — every analysis in one file

The smoke test is the simplest possible demonstration:

import { Simulation } from 'eecircuit-engine';

const sim = new Simulation();
await sim.start();

sim.setNetList(`Voltage divider
V1 vcc 0 DC 9
R1 vcc out 1k
R2 out 0 2k
.op
.end`);
const result = await sim.runSim();

console.log(result.variableNames);   // ["v(vcc)", "v(out)", "i(v1)"]
const idx = result.variableNames.indexOf("v(out)");
console.log(result.data[idx].values[0]);  // 6

That is all.

Code reviewed here