velxio/frontend/src/__tests__/ngspice-interactive.test.ts

122 lines
3.7 KiB
TypeScript
Raw Normal View History

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
2026-05-15 21:14:50 +07:00
/**
* 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);
});