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:
parent
e10492c6e6
commit
d41be4f48b
|
|
@ -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.
|
|
@ -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.
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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
Loading…
Reference in New Issue