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

303 lines
10 KiB
Markdown
Raw Normal View History

feat: electrical simulation via ngspice-WASM (eecircuit-engine) Adds full SPICE-accurate electrical simulation to Velxio, behind a lazy- loaded ⚡ toolbar toggle. Arduino / ESP32 / RP2040 sketches now co-simulate with real analog behaviour: correct voltages on wires, real I–V curves on LEDs, working potentiometers, NTC thermistors read by analogRead(), PWM driving RC filters, transistors, op-amps, diodes, MOSFETs, etc. Engine: eecircuit-engine (ngspice compiled to WebAssembly). Main bundle stays at 2.4 MB; the 20 MB SPICE chunk only loads when the user activates electrical mode. Disabled at build time via VITE_ELECTRICAL_SIM=false. Frontend additions: - simulation/spice/: SpiceEngine wrapper + lazy entry, NetlistBuilder with UnionFind over wires, componentToSpice mapping (24 metadataIds incl. real part numbers: 2N2222, 2N3055, BC547, IRF540, 2N7000, 1N4148, 1N4007, 1N4733, LEDs, NTC, op-amp ideal), CircuitScheduler with debounced coalescing, AVRSpiceBridge for quasi-static co-simulation. - store/useElectricalStore: Zustand slice, feature-flag aware. - components/analog-ui/: ⚡ toolbar toggle + SVG voltage overlay. - components/components-instruments/: Voltmeter, Ammeter probes. - 62 tests (spice-*, netlist-builder, component-to-spice, instruments). Sandbox (test/test_circuit/): 47-test validation sandbox that proved the approach (hand-rolled MNA baseline + ngspice pipeline) before porting to the app. Kept as reference. Docs: docs/wiki/circuit-emulation-*.md (13 engineering pages covering architecture, solvers, components, AVR bridge, gotchas, performance, integration plan, API reference, appendix) + electrical-simulation- user-guide.md (end-user facing). Reference plan: test/test_circuit/plan/phase_8_velxio_implementation.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:11:54 +07:00
# ngspice-WASM Integration
Location: [`test/test_circuit/src/spice/`](../../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`](https://www.npmjs.com/package/eecircuit-engine) 1.7.0 | Active, published npm | **Chosen** |
| [`tscircuit/ngspice`](https://github.com/tscircuit/ngspice) | GitHub only, not on npm | Deferred (requires fork+publish) |
| [ngspice.js](https://ngspice.js.org/) | Demo site, no npm API | Declined |
| [SpiceJS](https://www.spicejs.org/) | 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](https://eecircuit.com/). MIT licensed, has TypeScript types.
## Installation
```bash
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](circuit-emulation-performance.md).
## API surface (from `dist/main.d.ts`)
```typescript
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`](../../test/test_circuit/src/spice/SpiceEngine.js) wraps this with conveniences:
```javascript
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](https://ngspice.sourceforge.io/docs.html).
### Passive
```spice
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
```spice
* 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
```spice
* 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.)
```spice
* 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:
```spice
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](circuit-emulation-gotchas.md)):
- 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)
```spice
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
```spice
.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
```javascript
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](circuit-emulation-gotchas.md)).
## 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:
```javascript
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
- [`src/spice/SpiceEngine.js`](../../test/test_circuit/src/spice/SpiceEngine.js) — wrapper + helpers.
- [`src/spice/AVRSpiceBridge.js`](../../test/test_circuit/src/spice/AVRSpiceBridge.js) — quasi-static co-sim (see [AVR bridge](circuit-emulation-avr-bridge.md)).
- [`test/ngspice_smoke.test.js`](../../test/test_circuit/test/ngspice_smoke.test.js) — first working simulation.
- [`test/spice_*.test.js`](../../test/test_circuit/test/) — all 22 ngspice-backed tests.