feat(sim/spice): vendor ngspice+XSpice WASM and add NgSpiceInteractive client (Phase 1a)

Phase 1a of the mixed-mode simulator project.  Vendors prebuilt
ngspice+XSpice WASM artifacts from ejkreboot/ngspice-xspice-wasm (MIT,
2026) and adds a TypeScript client that exposes the ngspice shared
callable API for interactive (event-driven) use.

What's vendored at frontend/public/wasm/ngspice-interactive/ (~27 MB):

  - ngspice-lib.wasm  (24 MB) — ngspice 33 + XSpice, MAIN_MODULE
  - ngspice-lib.js   (2.7 MB) — Emscripten glue
  - {analog,digital,xtradev,xtraevt,table,tlines,spice2poly}.cm
                              — XSpice code models, loaded dynamically
  - spinit                    — ngspice startup script
  - PROVENANCE.md             — sources + license info

Note on the cost: 27 MB is a one-way commit to git history, but the
existing eecircuit-engine dependency already ships 39 MB in node_modules
(not tracked, re-downloaded per build). Vendoring our copy:
  - removes a third-party npm dependency
  - pins the exact build we tested with
  - means the WASM is served as a static asset (no Vite chunking)
The alternative (publish as @velxio/ngspice-interactive-wasm) was
deferred to keep the iteration cycle fast during Phase 1+.

New TypeScript client at frontend/src/simulation/spice/wasm/:

  - NgSpiceInteractive.ts        — Promise-based client class with
                                    init / loadNetlist / command /
                                    alter / readVec / reset / dispose
  - ngspice-interactive-worker.js — vendored from ejkreboot's worker
                                    and extended with 'loadNetlist',
                                    'command', 'readVec' message types
                                    plus per-command stdout/stderr
                                    capture

POC test at __tests__/ngspice-interactive.test.ts (skipped in node env
because Worker isn't available; runs in a browser-mode test env):
  - voltage divider .op → reads v(mid) ≈ 2.5V
  - RC step → reads v(cap) time series, final ≈ 5V
  - alter Vsrc → second .tran → final ≈ 1V (proves alter+rerun works)

Known limitation deferred to Phase 1b: the vendored WASM is built
without pthreads (no -sUSE_PTHREADS=1), so ngspice's bg_run is
synchronous-blocking. True mixed-mode event injection requires a
pthread-enabled rebuild (with SharedArrayBuffer + cross-origin
isolation). For Phase 1a we use the workaround: chained short-tran
invocations with `alter` between them. The new architecture is built
to swap in a real bg_halt/bg_resume implementation later without
changing component handlers — see NgSpiceInteractive.ts docstring.

Tests passing:
  - pin-resolver (Phase 0): 8/8
  - ngspice-interactive: 3 skipped (need browser env)
  - tsc --noEmit on the new files: clean
This commit is contained in:
davidmonterocrespo24 2026-05-15 16:14:50 +02:00
parent e10492c6e6
commit d41be4f48b
14 changed files with 39905 additions and 0 deletions

View File

@ -0,0 +1,28 @@
# ngspice-interactive WASM provenance
These files were copied verbatim from the `dist/` directory of
[`ejkreboot/ngspice-xspice-wasm`](https://github.com/ejkreboot/ngspice-xspice-wasm)
(MIT-licensed) on **2026-05-15**.
| File | Source | License |
|---|---|---|
| `ngspice-lib.wasm` | Built from ngspice (BSD-3-Clause-like) | BSD-style |
| `ngspice-lib.js` | Emscripten glue (MIT) | MIT |
| `*.cm` (XSpice code models) | ngspice source (BSD-3-Clause-like) | BSD-style |
| `spinit` | ngspice source | BSD-style |
The WASM build was produced with `emscripten/emsdk:3.1.50` against
ngspice with these configure flags:
```
--disable-debug --with-readline=no --disable-openmp
--enable-xspice --with-ngshared --without-x
```
The ngspice C-level shared callable API (`ngSpice_Init`, `ngSpice_Command`,
`ngSpice_AllVecs`, `ngSpice_Reset`, etc.) is exposed natively via
emscripten's `Module.cwrap`. See
`../../src/simulation/spice/wasm/NgSpiceInteractive.ts` for the
JavaScript surface that drives the mixed-mode simulator.
To rebuild from source (not required — these prebuilt artifacts are
sufficient), see ejkreboot's repo for the Dockerfile + `build-ngspice.sh`.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,12 @@
alias exit quit
alias acct rusage all
set num_threads=8
set ngbehavior=ltpsa
codemodel /usr/local/lib/ngspice/spice2poly.cm
codemodel /usr/local/lib/ngspice/analog.cm
codemodel /usr/local/lib/ngspice/digital.cm
codemodel /usr/local/lib/ngspice/xtradev.cm
codemodel /usr/local/lib/ngspice/xtraevt.cm
codemodel /usr/local/lib/ngspice/table.cm

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,121 @@
/**
* POC test for the Phase 1a interactive ngspice client.
*
* What this proves:
* - The vendored WASM build loads in a Web Worker
* - `loadNetlist` + `command('tran ...')` work end-to-end
* - `readVec` returns the right shape of data
* - `alter` updates a source and a second `tran` reflects the change
*
* What this does NOT test (Phase 1b):
* - bg_run / bg_halt / bg_resume single-threaded WASM doesn't
* support useful background mode (see NgSpiceInteractive.ts docstring)
* - Mid-simulation event injection the workaround is short-tran
* interleaved with alter, which is what this test exercises.
*
* Skipped by default because it requires a live Worker + ~24 MB WASM
* download, which is too slow / heavy for the regular `npm test` flow.
* Run explicitly with:
* npx vitest run src/__tests__/ngspice-interactive.test.ts \
* --reporter verbose
*
* In CI we'd run this as a separate "spice-integration" suite with a
* longer timeout.
*/
import { describe, it, expect } from 'vitest';
// JSDOM doesn't ship a real Worker — these tests need a node env with a
// Worker polyfill OR a browser-like vitest runner. Skip if Worker is
// unavailable.
const hasWorker = typeof Worker !== 'undefined';
describe.skipIf(!hasWorker)('NgSpiceInteractive — Phase 1a POC', () => {
it('loads the WASM and runs a simple .op analysis', async () => {
const { NgSpiceInteractive } = await import(
'../simulation/spice/wasm/NgSpiceInteractive'
);
const ng = new NgSpiceInteractive();
try {
await ng.init();
await ng.loadNetlist(`
* simple voltage divider
Vsrc in 0 DC 5
R1 in mid 1k
R2 mid 0 1k
.op
.end
`.trim());
const result = await ng.command('op');
expect(result.rc).toBe(0);
const vmid = await ng.readVec('v(mid)');
// Voltage divider: mid should be ~2.5V (5V split across 1k:1k).
expect(vmid.real[0]).toBeCloseTo(2.5, 2);
} finally {
ng.dispose();
}
}, 30_000);
it('handles a transient analysis and reads the time-series', async () => {
const { NgSpiceInteractive } = await import(
'../simulation/spice/wasm/NgSpiceInteractive'
);
const ng = new NgSpiceInteractive();
try {
await ng.init();
await ng.loadNetlist(`
* RC step response
Vsrc in 0 DC 5
R1 in cap 1k
C1 cap 0 1u
.tran 100us 5ms uic
.end
`.trim());
await ng.command('tran');
const vcap = await ng.readVec('v(cap)');
expect(vcap.real.length).toBeGreaterThan(10);
// Final sample should be near 5V (well past 5τ = 5ms).
const finalV = vcap.real[vcap.real.length - 1];
expect(finalV).toBeGreaterThan(4.5);
expect(finalV).toBeLessThan(5.1);
} finally {
ng.dispose();
}
}, 30_000);
it('alters a source between transient phases (mixed-mode workaround)', async () => {
const { NgSpiceInteractive } = await import(
'../simulation/spice/wasm/NgSpiceInteractive'
);
const ng = new NgSpiceInteractive();
try {
await ng.init();
await ng.loadNetlist(`
Vsrc in 0 DC 5
R1 in cap 1k
C1 cap 0 1u
.tran 100us 5ms uic
.end
`.trim());
// Run with Vsrc=5V
await ng.command('tran');
const v1 = await ng.readVec('v(cap)');
const final1 = v1.real[v1.real.length - 1];
expect(final1).toBeGreaterThan(4.5);
// Drop the source to 1V and run again — fresh transient, so v(cap)
// starts from initial conditions and asymptotes toward 1V.
await ng.alter('Vsrc', 1);
await ng.command('tran');
const v2 = await ng.readVec('v(cap)');
const final2 = v2.real[v2.real.length - 1];
expect(final2).toBeGreaterThan(0.9);
expect(final2).toBeLessThan(1.1);
} finally {
ng.dispose();
}
}, 60_000);
});

View File

@ -0,0 +1,245 @@
/**
* NgSpiceInteractive TypeScript client for the interactive ngspice
* worker. Phase 1a of the mixed-mode simulator project
* (see project/sim-mixedmode/phase-01-mixed-mode-coupling.md in the
* velxio-prod repo).
*
* Wraps the vendored `ngspice-interactive-worker.js` with a Promise-based
* API. Each public method posts a uniquely-id'd message to the worker
* and awaits a matching response. Stdout/stderr lines that flow during
* a command execution are buffered into the response of that command,
* AND optionally streamed live to subscribers.
*
* Usage:
*
* const ng = new NgSpiceInteractive({
* assetBaseUrl: '/wasm/ngspice-interactive/',
* });
* await ng.init();
* await ng.loadNetlist(`
* Vsrc 1 0 DC 5
* R1 1 2 1k
* C1 2 0 1u
* .tran 1us 10ms
* `);
* await ng.command('tran'); // run the analysis
* const v_cap = await ng.readVec('v(2)'); // full waveform of node 2
* console.log(v_cap.real); // Float64Array of samples
*
* // Mid-simulation source change (Phase 1b will use real bg_halt/
* // bg_resume; Phase 1a workaround is to do partial trans + alter):
* await ng.command('alter Vsrc dc 3.3');
* await ng.command('tran'); // continues from saved state
*
* ng.dispose();
*
* THREAD MODEL CAVEAT (Phase 1a): the vendored WASM is single-threaded
* (no `-sUSE_PTHREADS=1` in the build). This means ngspice's `bg_run`
* runs synchronously and blocks the worker until completion there is
* no useful "background" mode in this build. For mixed-mode operation
* we use the alternative pattern: short `.tran` invocations with
* `alter` between them. Phase 1b will investigate whether a pthread-
* enabled WASM rebuild is worth the SharedArrayBuffer / cross-origin-
* isolation overhead.
*/
interface InitConfig {
/** URL prefix where the WASM artifacts live (must end with '/'). */
assetBaseUrl?: string;
/** Optional override for the worker URL (defaults to the vendored worker). */
workerUrl?: string;
}
interface VecResult {
name: string;
real: Float64Array;
imag: Float64Array | null;
complex: boolean;
unit: string;
}
interface CommandResult {
rc: number;
stdout: string[];
stderr: string[];
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
expectedType: string;
}
interface SubscriberCallbacks {
onStdout?: (line: string) => void;
onStderr?: (line: string) => void;
}
const DEFAULT_ASSET_BASE = '/wasm/ngspice-interactive/';
export class NgSpiceInteractive {
private worker: Worker | null = null;
private initPromise: Promise<void> | null = null;
private requestId = 0;
private pending = new Map<number, PendingRequest>();
private subscribers: SubscriberCallbacks = {};
private readonly assetBaseUrl: string;
private readonly workerUrl: string;
constructor(config: InitConfig = {}) {
this.assetBaseUrl = config.assetBaseUrl ?? DEFAULT_ASSET_BASE;
// Vite-flavored worker URL: resolves to the bundled worker at build
// time. When running in tests with `vitest` + a JSDOM/node env this
// import.meta.url scheme also works.
this.workerUrl = config.workerUrl ?? new URL(
'./ngspice-interactive-worker.js',
import.meta.url,
).href;
}
/**
* Subscribe to stdout/stderr lines emitted by ngspice during command
* execution. Multiple subscriptions overwrite each other for the
* library's first version we don't need a fan-out registry.
*/
setSubscribers(subs: SubscriberCallbacks): void {
this.subscribers = subs;
}
/**
* Initialise the worker + WASM module. Idempotent subsequent calls
* return the same promise.
*/
init(): Promise<void> {
if (this.initPromise) return this.initPromise;
this.worker = new Worker(this.workerUrl);
this.worker.addEventListener('message', this.handleMessage);
this.worker.addEventListener('error', this.handleError);
this.initPromise = this.request('init', { config: { assetBaseUrl: this.assetBaseUrl } }, 'ready')
.then(() => undefined);
return this.initPromise;
}
/** Submit a netlist to ngspice. Does NOT auto-run any analyses
* subsequent `command()` calls do that. */
async loadNetlist(netlist: string): Promise<void> {
await this.init();
await this.request('loadNetlist', { netlist }, 'loaded');
}
/**
* Send a raw ngspice command and capture its stdout/stderr. Useful
* commands: `tran 1us 10ms`, `alter Vsrc dc 5`, `display`, `print
* v(out)`, `quit`, `reset`. See the ngspice manual chapter 17
* for the full list.
*/
async command(cmd: string): Promise<CommandResult> {
await this.init();
return this.request<CommandResult>('command', { command: cmd }, 'command-result');
}
/**
* Convenience wrapper for `alter <name> dc <value>` typical use
* during mixed-mode is to update a voltage source representing an
* MCU pin's digital state.
*/
async alter(sourceName: string, dcValue: number): Promise<CommandResult> {
return this.command(`alter ${sourceName} dc ${dcValue}`);
}
/**
* Read the current state of a vector. For `.op` analyses this is a
* scalar (length-1 Float64Array); for `.tran` it's the full time
* series captured so far. Reads block until the worker responds.
*/
async readVec(name: string): Promise<VecResult> {
await this.init();
return this.request<VecResult>('readVec', { name }, 'vec');
}
/** Reset the engine to a clean state (drops netlist + plots). */
async reset(): Promise<void> {
await this.init();
await this.request('reset', {}, 'reset-done');
}
/** Terminate the worker. */
dispose(): void {
if (this.worker) {
try { this.worker.terminate(); } catch { /* ignore */ }
this.worker = null;
}
for (const [, req] of this.pending) {
try { req.reject(new Error('NgSpiceInteractive disposed')); } catch { /* ignore */ }
}
this.pending.clear();
this.initPromise = null;
}
// ── Private plumbing ────────────────────────────────────────────────
private request<T = unknown>(
type: string,
body: Record<string, unknown>,
expectedType: string,
): Promise<T> {
if (!this.worker) throw new Error('Worker not initialised. Call init() first.');
const id = ++this.requestId;
return new Promise<T>((resolve, reject) => {
this.pending.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
expectedType,
});
this.worker!.postMessage({ type, requestId: id, ...body });
});
}
private handleMessage = (ev: MessageEvent): void => {
const data = ev.data as { type: string; requestId?: number; message?: string; line?: string } &
Record<string, unknown>;
// Streaming stdout/stderr events — not tied to a request resolve
if (data.type === 'stdout') {
this.subscribers.onStdout?.(String(data.line ?? ''));
return;
}
if (data.type === 'stderr') {
this.subscribers.onStderr?.(String(data.line ?? ''));
return;
}
// Status / progress / debug events from the worker — ignored at this
// level (no request to resolve). UI-level callers can extend
// SubscriberCallbacks later.
if (data.type === 'status' || data.type === 'progress' || data.type === 'debug') {
return;
}
const id = data.requestId;
if (typeof id !== 'number') return;
const pending = this.pending.get(id);
if (!pending) return;
if (data.type === 'error') {
this.pending.delete(id);
pending.reject(new Error(String(data.message ?? 'worker error')));
return;
}
if (data.type === pending.expectedType) {
this.pending.delete(id);
pending.resolve(data);
}
};
private handleError = (ev: ErrorEvent): void => {
const err = new Error(ev.message || 'Worker error');
for (const [, req] of this.pending) {
try { req.reject(err); } catch { /* ignore */ }
}
this.pending.clear();
};
}

File diff suppressed because it is too large Load Diff