feat(sim): Phase 1c F1 — NgSpiceNodeAdapter runs real WASM in Node tests
Loads the vendored ngspice-interactive WASM directly in the Vitest Node process, no Web Worker required. Implements the same SolverPort contract as NgSpiceWorkerAdapter, so production code and tests share ONE solver — closing the "no hybrid" gap. loadNgSpiceForNode (Node-only loader): - Reads ngspice-lib.js as text, wraps with a hoisted `var Module = config` so the emscripten singleton picks up our locateFile + callbacks. - Re-wires Module.onRuntimeInitialized to copy closure-local FS / HEAP* into Module._velxio_* (the vendored build doesn't export them via EXPORTED_RUNTIME_METHODS so direct Module.FS triggers an abort accessor). NgSpiceNodeAdapter: - bindApi (cwrap), registerCallbacks (no-op via addFunction), stageFilesystem (recursive mkdir + writeFile of model .cm + spinit), initialiseNgspice (null callback pointers; the build still solves fine without print/data hooks). - loadCircuit writes the netlist to /circuit.spc on the FS and issues `source /circuit.spc` — sidesteps `_malloc` (not exported by this build) that the obvious ngSpice_Circ path would need. - solve() dispatches op/tran/ac, reads requested vectors via ngGet_Vec_Info using the actual struct offsets verified against the live build dump: flags=8, realdata=12, imagdata=16, length=20. - alterSource issues `alter` for incremental re-solves. 5/5 SolverPort contract tests pass against real ngspice: - init idempotent - DC op solves a 100Ω/100Ω divider → V(mid) = 2.5 V exactly - omits requested vectors that don't exist - alterSource changes V1 → V(mid) tracks the new voltage - transient RC charge (τ=1ms) reaches >4.5V after 5τ Next: F2 — migrate the ~22 test files that use eecircuit-engine via `runNetlist` to this adapter. After F2, F3 deletes eecircuit-engine and `SpiceEngine.ts` for good. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5d64654668
commit
8db973675a
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* Phase 1c F1 smoke test — does the vendored WASM load cleanly in
|
||||||
|
* the Vitest Node process? If this fails, the entire Node adapter
|
||||||
|
* path is dead and we have to rethink F1.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
loadNgSpiceForNode,
|
||||||
|
__resetNgSpiceForTests,
|
||||||
|
} from '../simulation/spice/adapters/node/loadNgSpiceForNode';
|
||||||
|
|
||||||
|
describe('Phase 1c F1 — Node WASM loader', () => {
|
||||||
|
it(
|
||||||
|
'boots the ngspice emscripten module and exposes cwrap',
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
async () => {
|
||||||
|
__resetNgSpiceForTests();
|
||||||
|
const stdout: string[] = [];
|
||||||
|
const stderr: string[] = [];
|
||||||
|
const Module = await loadNgSpiceForNode({
|
||||||
|
onStdout: (t) => stdout.push(t),
|
||||||
|
onStderr: (t) => stderr.push(t),
|
||||||
|
});
|
||||||
|
expect(typeof Module.cwrap).toBe('function');
|
||||||
|
expect(typeof Module.addFunction).toBe('function');
|
||||||
|
expect(typeof Module.UTF8ToString).toBe('function');
|
||||||
|
// The vendored build doesn't export FS; the loader exposes it
|
||||||
|
// via _velxio_fs to esquivar the abort accessor.
|
||||||
|
expect(Module._velxio_fs).toBeDefined();
|
||||||
|
expect(typeof Module._velxio_fs?.writeFile).toBe('function');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('cwrap binds ngSpice_Reset (a known no-arg ngspice symbol)', async () => {
|
||||||
|
const Module = await loadNgSpiceForNode();
|
||||||
|
const reset = Module.cwrap('ngSpice_Reset', 'number', []);
|
||||||
|
expect(typeof reset).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addFunction is callable and returns a function-table pointer', async () => {
|
||||||
|
const Module = await loadNgSpiceForNode();
|
||||||
|
expect(typeof Module.addFunction).toBe('function');
|
||||||
|
const noop = () => 0;
|
||||||
|
const ptr = Module.addFunction(noop, 'iiii');
|
||||||
|
expect(typeof ptr).toBe('number');
|
||||||
|
expect(ptr).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
/**
|
||||||
|
* NgSpiceNodeAdapter integration test — runs the SAME SolverPort
|
||||||
|
* contract suite as solver-port-contract.test.ts but against the
|
||||||
|
* real WASM running in the Vitest Node process.
|
||||||
|
*
|
||||||
|
* If this passes, we have proof that production code and tests can
|
||||||
|
* share a single solver path (the "no hybrid" rule).
|
||||||
|
*
|
||||||
|
* Test cases include a real DC operating-point solve so we know
|
||||||
|
* the netlist pipeline + readVec extraction actually work.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, afterAll } from 'vitest';
|
||||||
|
import { NgSpiceNodeAdapter } from '../simulation/spice/adapters/NgSpiceNodeAdapter';
|
||||||
|
|
||||||
|
const adapter = new NgSpiceNodeAdapter();
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
adapter.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NgSpiceNodeAdapter — SolverPort contract on real WASM', () => {
|
||||||
|
it('init is idempotent', { timeout: 30_000 }, async () => {
|
||||||
|
await adapter.init();
|
||||||
|
await adapter.init();
|
||||||
|
// No throw.
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'loads + solves an .op on a simple divider, returns the expected v(net)',
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
async () => {
|
||||||
|
const netlist = [
|
||||||
|
'* simple divider',
|
||||||
|
'V1 1 0 DC 5',
|
||||||
|
'R1 1 mid 100',
|
||||||
|
'R2 mid 0 100',
|
||||||
|
'.end',
|
||||||
|
].join('\n');
|
||||||
|
await adapter.loadCircuit(netlist);
|
||||||
|
const result = await adapter.solve(
|
||||||
|
{ kind: 'op' },
|
||||||
|
{ vectorsOfInterest: ['v(1)', 'v(mid)'] },
|
||||||
|
);
|
||||||
|
expect(result.vectors.size).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(result.vectors.get('v(1)')?.real[0]).toBeCloseTo(5, 3);
|
||||||
|
expect(result.vectors.get('v(mid)')?.real[0]).toBeCloseTo(2.5, 3);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'omits missing vectors silently',
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
async () => {
|
||||||
|
const result = await adapter.solve(
|
||||||
|
{ kind: 'op' },
|
||||||
|
{ vectorsOfInterest: ['v(1)', 'v(does_not_exist)'] },
|
||||||
|
);
|
||||||
|
expect(result.vectors.has('v(1)')).toBe(true);
|
||||||
|
expect(result.vectors.has('v(does_not_exist)')).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'alterSource updates a V source and the next solve sees the new value',
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
async () => {
|
||||||
|
// Same divider as before — V1 is still loaded.
|
||||||
|
await adapter.alterSource('V1', 10);
|
||||||
|
const result = await adapter.solve(
|
||||||
|
{ kind: 'op' },
|
||||||
|
{ vectorsOfInterest: ['v(mid)'] },
|
||||||
|
);
|
||||||
|
expect(result.vectors.get('v(mid)')?.real[0]).toBeCloseTo(5, 3);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'transient analysis returns a time axis + samples for an RC step',
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
async () => {
|
||||||
|
// Single step at t=0 — uic forces V_cap(0)=0, then DC source
|
||||||
|
// charges through R into C. τ = R·C = 1k·1u = 1 ms.
|
||||||
|
const netlist = [
|
||||||
|
'* RC charge from a DC step',
|
||||||
|
'V1 1 0 DC 5',
|
||||||
|
'R1 1 mid 1k',
|
||||||
|
'C1 mid 0 1u IC=0',
|
||||||
|
'.end',
|
||||||
|
].join('\n');
|
||||||
|
await adapter.loadCircuit(netlist);
|
||||||
|
const result = await adapter.solve(
|
||||||
|
{ kind: 'tran', step: '1e-5', stop: '5e-3' },
|
||||||
|
{ vectorsOfInterest: ['v(mid)'] },
|
||||||
|
);
|
||||||
|
expect(result.timeAxis.length).toBeGreaterThan(10);
|
||||||
|
expect(result.vectors.get('v(mid)')?.real.length).toBe(result.timeAxis.length);
|
||||||
|
// After 5τ, vmid ≈ 0.993·5 ≈ 4.97 V.
|
||||||
|
const lastV = result.vectors.get('v(mid)')?.real.at(-1) ?? 0;
|
||||||
|
expect(lastV).toBeGreaterThan(4.5);
|
||||||
|
expect(lastV).toBeLessThan(5.1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
/**
|
||||||
|
* NgSpiceNodeAdapter — SolverPort implementation that runs the
|
||||||
|
* vendored ngspice WASM directly in the Vitest Node process, no
|
||||||
|
* Web Worker required.
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* loadNgSpiceForNode → emscripten Module (with _velxio_fs et al.
|
||||||
|
* patched on)
|
||||||
|
* ↓
|
||||||
|
* stageFilesystem — write the .cm files + spinit to the WASM FS
|
||||||
|
* ↓
|
||||||
|
* bindApi — Module.cwrap the C functions we need
|
||||||
|
* ↓
|
||||||
|
* initialiseNgspice — register callbacks, call ngSpice_Init,
|
||||||
|
* set xspice, source /spinit
|
||||||
|
*
|
||||||
|
* Once initialised, SolverPort methods translate domain calls to
|
||||||
|
* ngspice commands and read results via ngGet_Vec_Info.
|
||||||
|
*
|
||||||
|
* Concurrency: ngspice C API is not thread-safe; this adapter
|
||||||
|
* serialises every call. Vitest's worker-per-test-file isolation
|
||||||
|
* means each test file gets a fresh process and adapter.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import type {
|
||||||
|
SolverPort,
|
||||||
|
SolveAnalysis,
|
||||||
|
SolveResult,
|
||||||
|
SolveOptions,
|
||||||
|
SolveVector,
|
||||||
|
} from '../ports/SolverPort';
|
||||||
|
import {
|
||||||
|
loadNgSpiceForNode,
|
||||||
|
type NgSpiceEmscriptenModule,
|
||||||
|
} from './node/loadNgSpiceForNode';
|
||||||
|
|
||||||
|
const MODEL_FILES = [
|
||||||
|
'analog.cm',
|
||||||
|
'digital.cm',
|
||||||
|
'spice2poly.cm',
|
||||||
|
'table.cm',
|
||||||
|
'tlines.cm',
|
||||||
|
'xtradev.cm',
|
||||||
|
'xtraevt.cm',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ngspice vector_info struct field offsets (32-bit pointers).
|
||||||
|
// Confirmed against the live struct dump from the vendored build.
|
||||||
|
const VECTOR_INFO_FLAGS_OFFSET = 8;
|
||||||
|
const VECTOR_INFO_REALDATA_OFFSET = 12;
|
||||||
|
const VECTOR_INFO_IMAGDATA_OFFSET = 16;
|
||||||
|
const VECTOR_INFO_LENGTH_OFFSET = 20;
|
||||||
|
const VECTOR_FLAG_COMPLEX = 1 << 1;
|
||||||
|
|
||||||
|
interface NgSpiceApi {
|
||||||
|
init: (
|
||||||
|
print: number,
|
||||||
|
status: number,
|
||||||
|
exit: number,
|
||||||
|
data: number,
|
||||||
|
dataInit: number,
|
||||||
|
bg: number,
|
||||||
|
arg: number,
|
||||||
|
) => number;
|
||||||
|
command: (cmd: string) => number;
|
||||||
|
circ: (linesPtr: number) => number;
|
||||||
|
allVecs: (plot: string) => number;
|
||||||
|
curPlot: () => string;
|
||||||
|
getVecInfo: (name: string) => number;
|
||||||
|
reset: () => number;
|
||||||
|
nospiceinit: () => number;
|
||||||
|
setInputPath: (path: string) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultWasmDir(): string {
|
||||||
|
const here = fileURLToPath(import.meta.url);
|
||||||
|
return path.resolve(here, '../../../../../public/wasm/ngspice-interactive');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NgSpiceNodeAdapter implements SolverPort {
|
||||||
|
private module: NgSpiceEmscriptenModule | null = null;
|
||||||
|
private api: NgSpiceApi | null = null;
|
||||||
|
private initialised = false;
|
||||||
|
private initPromise: Promise<void> | null = null;
|
||||||
|
private readonly wasmDir: string;
|
||||||
|
private stdoutBuffer: string[] = [];
|
||||||
|
private stderrBuffer: string[] = [];
|
||||||
|
private callbackPointers: number[] = [];
|
||||||
|
|
||||||
|
constructor(opts: { wasmDir?: string } = {}) {
|
||||||
|
this.wasmDir = opts.wasmDir ?? defaultWasmDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (this.initialised) return;
|
||||||
|
if (this.initPromise) return this.initPromise;
|
||||||
|
this.initPromise = (async () => {
|
||||||
|
this.module = await loadNgSpiceForNode({
|
||||||
|
wasmDir: this.wasmDir,
|
||||||
|
onStdout: (text) => this.stdoutBuffer.push(text),
|
||||||
|
onStderr: (text) => this.stderrBuffer.push(text),
|
||||||
|
});
|
||||||
|
this.bindApi();
|
||||||
|
this.registerCallbacks();
|
||||||
|
this.stageFilesystem();
|
||||||
|
this.initialiseNgspice();
|
||||||
|
this.initialised = true;
|
||||||
|
})();
|
||||||
|
return this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindApi(): void {
|
||||||
|
if (!this.module) throw new Error('Module not ready');
|
||||||
|
const M = this.module;
|
||||||
|
this.api = {
|
||||||
|
init: M.cwrap('ngSpice_Init', 'number', [
|
||||||
|
'number', 'number', 'number', 'number', 'number', 'number', 'number',
|
||||||
|
]),
|
||||||
|
command: M.cwrap('ngSpice_Command', 'number', ['string']),
|
||||||
|
circ: M.cwrap('ngSpice_Circ', 'number', ['number']),
|
||||||
|
allVecs: M.cwrap('ngSpice_AllVecs', 'number', ['string']),
|
||||||
|
curPlot: M.cwrap('ngSpice_CurPlot', 'string', []),
|
||||||
|
getVecInfo: M.cwrap('ngGet_Vec_Info', 'number', ['string']),
|
||||||
|
reset: M.cwrap('ngSpice_Reset', 'number', []),
|
||||||
|
nospiceinit: M.cwrap('ngSpice_nospiceinit', 'number', []),
|
||||||
|
setInputPath: M.cwrap('ngCM_Input_Path', 'number', ['string']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerCallbacks(): void {
|
||||||
|
if (!this.module) throw new Error('Module not ready');
|
||||||
|
const M = this.module;
|
||||||
|
// ngspice C callback signatures (from sharedspice.h):
|
||||||
|
// SendChar(char*, int, void*) → 4 ints (i ptr, i id, ptr) but emscripten counts ptrs as i
|
||||||
|
// SendStat(char*, int, void*) → iiii
|
||||||
|
// ControlledExit(int, bool, bool, int, void*) → iiiiii
|
||||||
|
// SendData(vecvaluesall*, int, int, void*) → iiiii
|
||||||
|
// SendInitData(vecinfoall*, int, void*) → iiii
|
||||||
|
// BGThreadRunning(bool, int, void*) → iiii
|
||||||
|
const noop = () => 0;
|
||||||
|
this.callbackPointers = [
|
||||||
|
M.addFunction(noop, 'iiii'), // print
|
||||||
|
M.addFunction(noop, 'iiii'), // status
|
||||||
|
M.addFunction(noop, 'iiiiii'), // exit
|
||||||
|
M.addFunction(noop, 'iiiii'), // data
|
||||||
|
M.addFunction(noop, 'iiii'), // dataInit
|
||||||
|
M.addFunction(noop, 'iiii'), // bg
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private stageFilesystem(): void {
|
||||||
|
const M = this.module;
|
||||||
|
if (!M || !M._velxio_fs) throw new Error('Module FS not ready');
|
||||||
|
const FS = M._velxio_fs;
|
||||||
|
// Recursive mkdir — emscripten FS.mkdir is one level at a time
|
||||||
|
// and the default mounted root only has `/`.
|
||||||
|
const ensureDir = (full: string): void => {
|
||||||
|
const parts = full.split('/').filter((p) => p.length > 0);
|
||||||
|
let cur = '';
|
||||||
|
for (const p of parts) {
|
||||||
|
cur += '/' + p;
|
||||||
|
try { FS.mkdir(cur); } catch { /* exists */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ensureDir('/usr/local/lib/ngspice');
|
||||||
|
ensureDir('/usr/local/share/ngspice/scripts');
|
||||||
|
|
||||||
|
for (const name of MODEL_FILES) {
|
||||||
|
const data = readFileSync(path.join(this.wasmDir, name));
|
||||||
|
FS.writeFile(`/usr/local/lib/ngspice/${name}`, new Uint8Array(data));
|
||||||
|
}
|
||||||
|
const spinit = readFileSync(path.join(this.wasmDir, 'spinit'), 'utf8');
|
||||||
|
FS.writeFile('/usr/local/share/ngspice/scripts/spinit', spinit);
|
||||||
|
FS.writeFile('/spinit', spinit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initialiseNgspice(): void {
|
||||||
|
if (!this.api) throw new Error('API not ready');
|
||||||
|
this.api.nospiceinit();
|
||||||
|
// Pass 0 (null) for every callback — ngspice tolerates null and
|
||||||
|
// skips the corresponding event. This sidesteps a
|
||||||
|
// "function signature mismatch" we hit when feeding addFunction
|
||||||
|
// pointers; investigate the worker's exact emscripten build
|
||||||
|
// settings later if we need real callbacks (progress / stderr).
|
||||||
|
const rc = this.api.init(0, 0, 0, 0, 0, 0, 0);
|
||||||
|
if (rc !== 0) throw new Error(`ngSpice_Init returned ${rc}`);
|
||||||
|
this.api.setInputPath('/');
|
||||||
|
this.api.command('set xspice_enabled');
|
||||||
|
this.api.command('source /spinit');
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCircuit(netlist: string): Promise<void> {
|
||||||
|
await this.init();
|
||||||
|
if (!this.module || !this.api || !this.module._velxio_fs) throw new Error('not ready');
|
||||||
|
// The build doesn't export `_malloc`, so we can't construct the
|
||||||
|
// char** the `ngSpice_Circ` API needs from JS. Workaround: write
|
||||||
|
// the netlist to the virtual FS and ask ngspice to `source` it.
|
||||||
|
this.module._velxio_fs.writeFile('/circuit.spc', netlist);
|
||||||
|
const rc = this.api.command('source /circuit.spc');
|
||||||
|
if (rc !== 0) throw new Error(`source /circuit.spc returned ${rc}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async solve(analysis: SolveAnalysis, options: SolveOptions): Promise<SolveResult> {
|
||||||
|
await this.init();
|
||||||
|
if (!this.api) throw new Error('not ready');
|
||||||
|
const t0 = performance.now();
|
||||||
|
this.stderrBuffer.length = 0;
|
||||||
|
this.stdoutBuffer.length = 0;
|
||||||
|
|
||||||
|
// ngspice has two ways to run an analysis:
|
||||||
|
// .control / .endc block in the netlist (ran by 'source')
|
||||||
|
// bare `op` / `tran ...` / `ac ...` commands after sourcing
|
||||||
|
// Bare commands often fail silently in interactive mode unless
|
||||||
|
// a circuit is "current"; the safer pattern is to inject the
|
||||||
|
// analysis directive INTO the netlist before source. But since
|
||||||
|
// the source already ran, we issue `run` which runs whatever
|
||||||
|
// last `.tran` / `.op` directive was in the netlist + falls
|
||||||
|
// back to our manual analysis card.
|
||||||
|
const cmd = (() => {
|
||||||
|
switch (analysis.kind) {
|
||||||
|
case 'op': return 'op';
|
||||||
|
case 'tran': return `tran ${analysis.step} ${analysis.stop}`;
|
||||||
|
case 'ac': return `ac ${analysis.sweep} ${analysis.points} ${analysis.fstart} ${analysis.fstop}`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const rc = this.api.command(cmd);
|
||||||
|
if (rc !== 0) throw new Error(`ngspice command '${cmd}' returned ${rc}`);
|
||||||
|
|
||||||
|
|
||||||
|
const vectors = new Map<string, SolveVector>();
|
||||||
|
let timeAxis: Float64Array = new Float64Array(0);
|
||||||
|
|
||||||
|
for (const name of options.vectorsOfInterest) {
|
||||||
|
const vec = this.readVector(name);
|
||||||
|
if (vec) vectors.set(name.toLowerCase(), vec);
|
||||||
|
}
|
||||||
|
if (analysis.kind === 'tran') {
|
||||||
|
const t = this.readVector('time');
|
||||||
|
if (t) timeAxis = t.real;
|
||||||
|
}
|
||||||
|
|
||||||
|
const solveMs = performance.now() - t0;
|
||||||
|
return {
|
||||||
|
analysis,
|
||||||
|
vectors,
|
||||||
|
timeAxis,
|
||||||
|
solveMs,
|
||||||
|
warnings: [...this.stderrBuffer, ...this.stdoutBuffer.map((s) => `stdout: ${s}`)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async alterSource(name: string, dcValue: number): Promise<void> {
|
||||||
|
await this.init();
|
||||||
|
if (!this.api) throw new Error('not ready');
|
||||||
|
this.api.command(`alter ${name} dc ${dcValue}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.api) {
|
||||||
|
try { this.api.reset(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
this.initialised = false;
|
||||||
|
this.initPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readVector(name: string): SolveVector | null {
|
||||||
|
if (!this.module || !this.api) return null;
|
||||||
|
const M = this.module;
|
||||||
|
let infoPtr = this.api.getVecInfo(name);
|
||||||
|
if (!infoPtr && !name.includes('.')) {
|
||||||
|
const plot = this.api.curPlot();
|
||||||
|
if (plot) infoPtr = this.api.getVecInfo(`${plot}.${name}`);
|
||||||
|
}
|
||||||
|
if (!infoPtr) return null;
|
||||||
|
const heap32 = M._velxio_heap32;
|
||||||
|
const heapu32 = M._velxio_heapu32;
|
||||||
|
const heapf64 = M._velxio_heapf64;
|
||||||
|
if (!heap32 || !heapu32 || !heapf64) return null;
|
||||||
|
const realDataPtr = heapu32[(infoPtr + VECTOR_INFO_REALDATA_OFFSET) >> 2]!;
|
||||||
|
const imagDataPtr = heapu32[(infoPtr + VECTOR_INFO_IMAGDATA_OFFSET) >> 2]!;
|
||||||
|
const length = heap32[(infoPtr + VECTOR_INFO_LENGTH_OFFSET) >> 2]!;
|
||||||
|
const flags = heap32[(infoPtr + VECTOR_INFO_FLAGS_OFFSET) >> 2]!;
|
||||||
|
const complex = (flags & VECTOR_FLAG_COMPLEX) !== 0;
|
||||||
|
if (!realDataPtr || length <= 0) return null;
|
||||||
|
const real = new Float64Array(heapf64.buffer, realDataPtr, length).slice();
|
||||||
|
const imag = complex && imagDataPtr
|
||||||
|
? new Float64Array(heapf64.buffer, imagDataPtr, length).slice()
|
||||||
|
: null;
|
||||||
|
return { name: name.toLowerCase(), real, imag };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
/**
|
||||||
|
* loadNgSpiceForNode — runtime loader for the vendored ngspice
|
||||||
|
* emscripten module in a Node (Vitest) process.
|
||||||
|
*
|
||||||
|
* The vendored `ngspice-lib.js` is an emscripten singleton build
|
||||||
|
* (not MODULARIZE), so the obvious `require('./ngspice-lib.js')`
|
||||||
|
* pattern doesn't pass a config to the Module factory. The CJS
|
||||||
|
* module evaluates `var Module = typeof Module != 'undefined' ?
|
||||||
|
* Module : {}` in its own scope, which can't see
|
||||||
|
* `globalThis.Module`.
|
||||||
|
*
|
||||||
|
* Workaround: read the source as text, wrap with a hoisted
|
||||||
|
* `var Module = config;` so the emscripten guard finds Module
|
||||||
|
* already defined (var hoisting + non-redeclaration semantics make
|
||||||
|
* the two `var Module` lines refer to the same variable), and
|
||||||
|
* evaluate via `new Function`. Result: our `config` survives and
|
||||||
|
* `locateFile` + callbacks work as expected.
|
||||||
|
*
|
||||||
|
* Only used in Node test contexts. The browser path
|
||||||
|
* (`NgSpiceWorkerAdapter`) goes through the Web Worker which evaluates
|
||||||
|
* the lib in its own scope.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subset of the emscripten Module surface we use. The vendored
|
||||||
|
* build adds plenty more, but we only depend on these.
|
||||||
|
*/
|
||||||
|
export interface NgSpiceEmscriptenModule {
|
||||||
|
cwrap: (
|
||||||
|
name: string,
|
||||||
|
returnType: string | null,
|
||||||
|
argTypes: readonly string[],
|
||||||
|
) => (...args: unknown[]) => unknown;
|
||||||
|
addFunction: (fn: (...args: unknown[]) => unknown, signature: string) => number;
|
||||||
|
removeFunction: (ptr: number) => void;
|
||||||
|
UTF8ToString: (ptr: number) => string;
|
||||||
|
stringToUTF8: (str: string, ptr: number, max: number) => void;
|
||||||
|
lengthBytesUTF8: (str: string) => number;
|
||||||
|
_malloc: (bytes: number) => number;
|
||||||
|
_free: (ptr: number) => void;
|
||||||
|
_velxio_heap8?: Int8Array;
|
||||||
|
_velxio_heap32?: Int32Array;
|
||||||
|
_velxio_heapu32?: Uint32Array;
|
||||||
|
_velxio_heapf64?: Float64Array;
|
||||||
|
/**
|
||||||
|
* Note: the vendored build does NOT include `FS` in
|
||||||
|
* EXPORTED_RUNTIME_METHODS, so `Module.FS` triggers an abort
|
||||||
|
* accessor on this build. We expose the closure-captured locals
|
||||||
|
* via the `_velxio_*` namespace instead — see the wrapper in
|
||||||
|
* loadNgSpiceForNode.
|
||||||
|
*/
|
||||||
|
_velxio_fs?: {
|
||||||
|
writeFile: (path: string, data: Uint8Array | string) => void;
|
||||||
|
mkdir: (path: string) => void;
|
||||||
|
analyzePath: (path: string) => { exists: boolean };
|
||||||
|
};
|
||||||
|
_velxio_env?: Record<string, string>;
|
||||||
|
noInitialRun: boolean;
|
||||||
|
print?: (text: string) => void;
|
||||||
|
printErr?: (text: string) => void;
|
||||||
|
locateFile?: (path: string) => string;
|
||||||
|
onRuntimeInitialized?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadNgSpiceConfig {
|
||||||
|
/** Directory containing ngspice-lib.{js,wasm} + the .cm files. */
|
||||||
|
wasmDir: string;
|
||||||
|
/** Optional stdout sink (one line at a time). */
|
||||||
|
onStdout?: (text: string) => void;
|
||||||
|
/** Optional stderr sink. */
|
||||||
|
onStderr?: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultWasmDir(): string {
|
||||||
|
// Resolve relative to this file's package location:
|
||||||
|
// frontend/src/simulation/spice/adapters/node/loadNgSpiceForNode.ts
|
||||||
|
// → frontend/public/wasm/ngspice-interactive
|
||||||
|
const here = fileURLToPath(import.meta.url);
|
||||||
|
return path.resolve(here, '../../../../../../public/wasm/ngspice-interactive');
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedModule: NgSpiceEmscriptenModule | null = null;
|
||||||
|
let cachedInit: Promise<NgSpiceEmscriptenModule> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the ngspice WASM module in the current Node process. Returns
|
||||||
|
* the initialised Module on success.
|
||||||
|
*
|
||||||
|
* Caches the singleton — calling twice returns the same Module.
|
||||||
|
* emscripten singleton builds can't be re-initialised within a
|
||||||
|
* process, so dispose() is best-effort (resets ngspice state via
|
||||||
|
* `ngSpice_Reset`, doesn't free the WASM).
|
||||||
|
*/
|
||||||
|
export function loadNgSpiceForNode(
|
||||||
|
config: Partial<LoadNgSpiceConfig> = {},
|
||||||
|
): Promise<NgSpiceEmscriptenModule> {
|
||||||
|
if (cachedInit) return cachedInit;
|
||||||
|
|
||||||
|
const wasmDir = config.wasmDir ?? defaultWasmDir();
|
||||||
|
const libPath = path.join(wasmDir, 'ngspice-lib.js');
|
||||||
|
|
||||||
|
const moduleConfig: Partial<NgSpiceEmscriptenModule> = {
|
||||||
|
noInitialRun: true,
|
||||||
|
locateFile: (filename) => {
|
||||||
|
// Resolve all asset files (.wasm, .cm, etc.) relative to wasmDir.
|
||||||
|
return path.join(wasmDir, filename);
|
||||||
|
},
|
||||||
|
print: (text: string) => config.onStdout?.(text),
|
||||||
|
printErr: (text: string) => config.onStderr?.(text),
|
||||||
|
};
|
||||||
|
|
||||||
|
const src = readFileSync(libPath, 'utf8');
|
||||||
|
// Wrapping strategy:
|
||||||
|
// 1. Hoisted `var Module = config` so the emscripten body's
|
||||||
|
// `var Module = typeof Module != 'undefined' ? Module : {}`
|
||||||
|
// finds Module already defined and keeps our config.
|
||||||
|
// 2. After the emscripten body runs, FS / HEAP* are bound to
|
||||||
|
// `var` declarations inside the same closure. We re-wire
|
||||||
|
// `Module.onRuntimeInitialized` to ALSO copy those locals
|
||||||
|
// onto Module so external code can read them via
|
||||||
|
// `Module.FS`, `Module.HEAPU32`, etc. The build doesn't
|
||||||
|
// include FS in EXPORTED_RUNTIME_METHODS so this is the
|
||||||
|
// only path that doesn't require recompiling.
|
||||||
|
const wrapped = `
|
||||||
|
var Module = config;
|
||||||
|
${src}
|
||||||
|
var __velxioOriginalInit = Module.onRuntimeInitialized;
|
||||||
|
Module.onRuntimeInitialized = function () {
|
||||||
|
// Build does not export FS / HEAP* in EXPORTED_RUNTIME_METHODS;
|
||||||
|
// those keys on Module trigger abort accessors. Assign to the
|
||||||
|
// _velxio_ namespace instead and the NgSpiceNodeAdapter reads
|
||||||
|
// from there.
|
||||||
|
if (typeof FS !== 'undefined') Module._velxio_fs = FS;
|
||||||
|
if (typeof HEAP8 !== 'undefined') Module._velxio_heap8 = HEAP8;
|
||||||
|
if (typeof HEAP32 !== 'undefined') Module._velxio_heap32 = HEAP32;
|
||||||
|
if (typeof HEAPU32 !== 'undefined') Module._velxio_heapu32 = HEAPU32;
|
||||||
|
if (typeof HEAPF64 !== 'undefined') Module._velxio_heapf64 = HEAPF64;
|
||||||
|
if (typeof ENV !== 'undefined') Module._velxio_env = ENV;
|
||||||
|
if (__velxioOriginalInit) __velxioOriginalInit();
|
||||||
|
};
|
||||||
|
return Module;
|
||||||
|
`;
|
||||||
|
const factory = new Function('config', 'require', '__dirname', '__filename', wrapped) as (
|
||||||
|
config: unknown,
|
||||||
|
req: typeof require,
|
||||||
|
dirname: string,
|
||||||
|
filename: string,
|
||||||
|
) => NgSpiceEmscriptenModule;
|
||||||
|
|
||||||
|
const req = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
cachedInit = new Promise<NgSpiceEmscriptenModule>((resolve, reject) => {
|
||||||
|
moduleConfig.onRuntimeInitialized = () => {
|
||||||
|
cachedModule = moduleConfig as NgSpiceEmscriptenModule;
|
||||||
|
resolve(cachedModule);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
factory(moduleConfig, req, wasmDir, libPath);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
setTimeout(() => reject(new Error('ngspice WASM init timeout (30s)')), 30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
return cachedInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the cached module reference (NOT free the WASM — that's not
|
||||||
|
* supported by emscripten singletons). Subsequent `loadNgSpiceForNode`
|
||||||
|
* calls will re-init — which only works if the previous Module was
|
||||||
|
* reset via `ngSpice_Reset`. Best-effort.
|
||||||
|
*/
|
||||||
|
export function __resetNgSpiceForTests(): void {
|
||||||
|
cachedModule = null;
|
||||||
|
cachedInit = null;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue