1055 lines
28 KiB
JavaScript
1055 lines
28 KiB
JavaScript
/**
|
|
* Interactive ngspice worker — vendored + extended from
|
|
* `ejkreboot/ngspice-xspice-wasm` (MIT, 2026). The original repo provides
|
|
* a batch-only client; we vendor the WASM build as-is and add new message
|
|
* types for mixed-mode (event-driven) coupling between an MCU simulator
|
|
* and the analog SPICE solver.
|
|
*
|
|
* Original batch API (preserved for backward-compat with non-interactive
|
|
* callers):
|
|
* 'init', 'run', 'reset'
|
|
*
|
|
* New interactive message types (Phase 1a of the mixed-mode simulator
|
|
* roadmap — see `project/sim-mixedmode/phase-01-mixed-mode-coupling.md`
|
|
* in the velxio-prod repo):
|
|
* 'loadNetlist' — send a netlist to ngspice without running yet
|
|
* (so subsequent commands can drive analysis manually)
|
|
* 'command' — send a raw ngspice command string and return stdout
|
|
* captured during its execution (`alter`, `tran`,
|
|
* `display`, etc.)
|
|
* 'readVec' — read a vector's current value(s) by name
|
|
*
|
|
* Not yet implemented (Phase 1b — needs investigation of single-thread
|
|
* WASM workarounds for ngspice background mode):
|
|
* 'bgRun', 'bgHalt', 'bgResume' — true continuation across alter calls.
|
|
* Current workaround at the client level: chain `tran` invocations
|
|
* with shrinking dt, each one alter-ing the source from the previous
|
|
* state. See NgSpiceInteractive.ts for the workaround impl.
|
|
*/
|
|
|
|
const MODEL_FILES = [
|
|
'analog.cm',
|
|
'digital.cm',
|
|
'spice2poly.cm',
|
|
'table.cm',
|
|
'tlines.cm',
|
|
'xtradev.cm',
|
|
'xtraevt.cm',
|
|
];
|
|
|
|
// vecvaluesall struct offsets (used by onData callback during simulation)
|
|
const VECVALUESALL_COUNT_OFFSET = 0;
|
|
const VECVALUESALL_VALUES_OFFSET = 8;
|
|
const VECVALUES_NAME_OFFSET = 0;
|
|
const VECVALUES_REAL_OFFSET = 8;
|
|
const VECVALUES_IS_SCALE_OFFSET = 24;
|
|
|
|
// vector_info struct offsets (used for post-simulation data extraction)
|
|
const VECTOR_INFO_NAME_OFFSET = 0;
|
|
const VECTOR_INFO_TYPE_OFFSET = 4;
|
|
const VECTOR_INFO_FLAGS_OFFSET = 8;
|
|
const VECTOR_INFO_REALDATA_OFFSET = 12;
|
|
const VECTOR_INFO_IMAGDATA_OFFSET = 16;
|
|
const VECTOR_INFO_LENGTH_OFFSET = 20;
|
|
|
|
// vecinfoall struct offsets (used by onDataInit callback)
|
|
const VECINFOALL_COUNT_OFFSET = 16;
|
|
const VECINFOALL_VECS_OFFSET = 20;
|
|
const VECINFO_NAME_OFFSET = 4;
|
|
|
|
// ngspice vector type flag for complex data
|
|
const VF_COMPLEX = 0x400;
|
|
|
|
// Analysis types that produce scalar results (no sweep)
|
|
const SCALAR_ANALYSIS_TYPES = new Set(['op', 'tf', 'sens']);
|
|
|
|
let moduleConfig = null;
|
|
let moduleReady = null;
|
|
let filesystemReady = false;
|
|
let api = null;
|
|
let callbackPointers = null;
|
|
let ngspiceInitialized = false;
|
|
let currentRun = null;
|
|
|
|
function postDebug(event, details = {}) {
|
|
self.postMessage({
|
|
type: 'debug',
|
|
requestId: currentRun?.requestId,
|
|
event,
|
|
details,
|
|
});
|
|
}
|
|
|
|
self.addEventListener('message', async (event) => {
|
|
const data = event.data || {};
|
|
|
|
try {
|
|
// ── Original batch API (preserved) ──────────────────────────────
|
|
if (data.type === 'init') {
|
|
await ensureSession(data.config || {});
|
|
self.postMessage({ type: 'ready', requestId: data.requestId });
|
|
return;
|
|
}
|
|
|
|
if (data.type === 'run') {
|
|
await runSimulation(data.requestId, data.netlist || '');
|
|
return;
|
|
}
|
|
|
|
if (data.type === 'reset') {
|
|
resetNgspice();
|
|
self.postMessage({ type: 'reset-done', requestId: data.requestId });
|
|
return;
|
|
}
|
|
|
|
// ── New interactive API (Phase 1a) ──────────────────────────────
|
|
if (data.type === 'loadNetlist') {
|
|
await handleLoadNetlist(data.requestId, data.netlist || '');
|
|
return;
|
|
}
|
|
|
|
if (data.type === 'command') {
|
|
handleCommand(data.requestId, data.command || '');
|
|
return;
|
|
}
|
|
|
|
if (data.type === 'readVec') {
|
|
handleReadVec(data.requestId, data.name || '');
|
|
return;
|
|
}
|
|
|
|
if (data.type === 'listVectors') {
|
|
handleListVectors(data.requestId);
|
|
return;
|
|
}
|
|
|
|
throw new Error(`Unknown message type: ${data.type}`);
|
|
} catch (error) {
|
|
self.postMessage({
|
|
type: 'error',
|
|
requestId: data.requestId,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
});
|
|
|
|
// ── Interactive handlers (Phase 1a) ─────────────────────────────────
|
|
|
|
/**
|
|
* Load a netlist into ngspice without immediately running an analysis.
|
|
* The netlist may include `.tran`, `.dc`, etc. directives that fire
|
|
* automatically when ngspice processes them — or it may be purely
|
|
* structural (components, sources, models) with analysis triggered
|
|
* later via separate `command('tran ...')` calls.
|
|
*/
|
|
async function handleLoadNetlist(requestId, netlist) {
|
|
if (!netlist.trim()) {
|
|
throw new Error('Netlist is empty.');
|
|
}
|
|
resetNgspice();
|
|
await ensureSession(moduleConfig || {});
|
|
const lines = buildCircuitLines(netlist);
|
|
const allocations = allocateCStringArray(lines);
|
|
try {
|
|
const rc = api.circ(allocations.arrayPointer);
|
|
if (rc !== 0) {
|
|
throw new Error(`ngSpice_Circ failed with status ${rc}.`);
|
|
}
|
|
self.postMessage({ type: 'loaded', requestId });
|
|
} finally {
|
|
freeCStringArray(allocations);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a raw command string to ngspice. Stdout / stderr lines captured
|
|
* during the command's execution are buffered and returned in the
|
|
* response, plus also emitted as 'stdout' / 'stderr' events (compatible
|
|
* with the existing onPrint callback flow used by batch mode).
|
|
*/
|
|
function handleCommand(requestId, command) {
|
|
if (!command.trim()) {
|
|
throw new Error('Command is empty.');
|
|
}
|
|
const capture = beginCommandCapture(requestId);
|
|
try {
|
|
const rc = api.command(command);
|
|
self.postMessage({
|
|
type: 'command-result',
|
|
requestId,
|
|
rc,
|
|
stdout: capture.stdout.slice(),
|
|
stderr: capture.stderr.slice(),
|
|
});
|
|
} finally {
|
|
endCommandCapture(capture);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the current state of a named vector. For DC operating point this
|
|
* is a single scalar; for `.tran` it's the entire time series of samples
|
|
* captured so far.
|
|
*/
|
|
function handleReadVec(requestId, vectorName) {
|
|
const infoPtr = api.getVecInfo(vectorName);
|
|
if (!infoPtr) {
|
|
throw new Error(`Vector '${vectorName}' not found. Run an analysis first.`);
|
|
}
|
|
const data = readVectorData(infoPtr, /* readImag */ true);
|
|
const transferables = [data.real.buffer];
|
|
if (data.imag) transferables.push(data.imag.buffer);
|
|
self.postMessage(
|
|
{
|
|
type: 'vec',
|
|
requestId,
|
|
name: vectorName,
|
|
real: data.real,
|
|
imag: data.imag,
|
|
complex: data.complex,
|
|
unit: data.unit,
|
|
},
|
|
transferables,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Enumerate every vector in the current plot. Phase 1d #6.
|
|
* `ngSpice_AllVecs` returns a NULL-terminated char** array; we walk
|
|
* it until the first 0 pointer and decode each cstring. Names are
|
|
* case-preserved — getVecInfo lookups care about case.
|
|
*/
|
|
function handleListVectors(requestId) {
|
|
const plot = api.curPlot && api.curPlot();
|
|
const names = [];
|
|
if (plot) {
|
|
const arrPtr = api.allVecs(plot);
|
|
if (arrPtr) {
|
|
for (let i = 0; i < 4096; i++) {
|
|
const ptr = HEAPU32[(arrPtr >> 2) + i];
|
|
if (!ptr) break;
|
|
names.push(Module.UTF8ToString(ptr));
|
|
}
|
|
}
|
|
}
|
|
self.postMessage({ type: 'vector-list', requestId, names });
|
|
}
|
|
|
|
// ── Command-capture machinery (Phase 1a) ────────────────────────────
|
|
//
|
|
// onPrint pushes to currentRun.log.stdout when a batch run is active.
|
|
// For interactive commands we need our own capture buffer so the
|
|
// caller of `command()` gets the lines that command produced.
|
|
|
|
let activeCommandCapture = null;
|
|
|
|
function beginCommandCapture(requestId) {
|
|
activeCommandCapture = {
|
|
requestId,
|
|
stdout: [],
|
|
stderr: [],
|
|
};
|
|
return activeCommandCapture;
|
|
}
|
|
|
|
function endCommandCapture(capture) {
|
|
if (activeCommandCapture === capture) {
|
|
activeCommandCapture = null;
|
|
}
|
|
}
|
|
|
|
// freeCStringArray and allocateCStringArray are defined further down in
|
|
// the file as part of the existing batch-mode infrastructure — reused
|
|
// here.
|
|
|
|
async function runSimulation(requestId, netlist) {
|
|
if (!netlist.trim()) {
|
|
throw new Error('Netlist is empty.');
|
|
}
|
|
|
|
if (currentRun) {
|
|
throw new Error('A library simulation is already in progress.');
|
|
}
|
|
|
|
resetNgspice();
|
|
await ensureSession(moduleConfig || {});
|
|
|
|
currentRun = {
|
|
requestId,
|
|
finalTime: extractTranFinalTime(netlist),
|
|
lastProgress: 0,
|
|
lastCurrentTime: 0,
|
|
lastEmitAt: 0,
|
|
timeVectorName: 'time',
|
|
};
|
|
|
|
postDebug('run-start', { finalTime: currentRun.finalTime, netlistLines: netlist.split(/\r?\n/).length });
|
|
|
|
self.postMessage({ type: 'status', requestId, message: 'Submitting circuit to shared ngspice…' });
|
|
|
|
const circuitLines = buildCircuitLines(netlist);
|
|
const allocations = allocateCStringArray(circuitLines);
|
|
|
|
try {
|
|
const rc = api.circ(allocations.arrayPointer);
|
|
if (rc !== 0) {
|
|
throw new Error(`ngSpice_Circ failed with status ${rc}.`);
|
|
}
|
|
|
|
emitProgress(true);
|
|
|
|
const analyses = extractAllAnalyses();
|
|
const transferables = collectTransferables(analyses);
|
|
|
|
self.postMessage(
|
|
{
|
|
type: 'done',
|
|
requestId,
|
|
exitCode: rc,
|
|
finalTime: currentRun.finalTime,
|
|
progress: currentRun.lastProgress,
|
|
analyses,
|
|
},
|
|
transferables,
|
|
);
|
|
} finally {
|
|
freeCStringArray(allocations);
|
|
currentRun = null;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Post-simulation data extraction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function extractAllAnalyses() {
|
|
const plotNames = readAllPlotNames();
|
|
const analyses = [];
|
|
|
|
for (const plotName of plotNames) {
|
|
if (plotName === 'const') {
|
|
continue;
|
|
}
|
|
|
|
const analysis = extractPlotAnalysis(plotName);
|
|
if (analysis) {
|
|
analyses.push(analysis);
|
|
}
|
|
}
|
|
|
|
postDebug('extract-analyses', { plotCount: plotNames.length, analysisCount: analyses.length });
|
|
return analyses;
|
|
}
|
|
|
|
function readAllPlotNames() {
|
|
const plotsPtr = api.allPlots();
|
|
if (!plotsPtr) {
|
|
return [];
|
|
}
|
|
|
|
const names = [];
|
|
let offset = plotsPtr;
|
|
while (true) {
|
|
const strPtr = HEAPU32[offset >> 2];
|
|
if (!strPtr) {
|
|
break;
|
|
}
|
|
names.push(Module.UTF8ToString(strPtr));
|
|
offset += 4;
|
|
}
|
|
return names;
|
|
}
|
|
|
|
function readAllVecNames(plotName) {
|
|
const vecsPtr = api.allVecs(plotName);
|
|
if (!vecsPtr) {
|
|
return [];
|
|
}
|
|
|
|
const names = [];
|
|
let offset = vecsPtr;
|
|
while (true) {
|
|
const strPtr = HEAPU32[offset >> 2];
|
|
if (!strPtr) {
|
|
break;
|
|
}
|
|
names.push(Module.UTF8ToString(strPtr));
|
|
offset += 4;
|
|
}
|
|
return names;
|
|
}
|
|
|
|
function extractPlotAnalysis(plotName) {
|
|
const vecNames = readAllVecNames(plotName);
|
|
if (vecNames.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const analysisType = detectAnalysisType(plotName);
|
|
const isComplex = analysisType === 'ac';
|
|
const isScalar = SCALAR_ANALYSIS_TYPES.has(analysisType);
|
|
|
|
let sweepVec = null;
|
|
const dataVecs = [];
|
|
|
|
for (const vecName of vecNames) {
|
|
const qualifiedName = `${plotName}.${vecName}`;
|
|
const infoPtr = api.getVecInfo(qualifiedName);
|
|
if (!infoPtr) {
|
|
continue;
|
|
}
|
|
|
|
const vecData = readVectorData(infoPtr, isComplex);
|
|
if (!vecData) {
|
|
continue;
|
|
}
|
|
|
|
const typeFlags = HEAP32[(infoPtr + VECTOR_INFO_TYPE_OFFSET) >> 2];
|
|
// SV_TIME=1, SV_FREQUENCY=2 are scale (independent) vectors
|
|
const svType = typeFlags & 0xFF;
|
|
const isScale = svType === 1 || svType === 2;
|
|
|
|
if (isScale) {
|
|
sweepVec = { name: vecName, ...vecData };
|
|
} else {
|
|
dataVecs.push({ name: vecName, ...vecData });
|
|
}
|
|
}
|
|
|
|
if (isScalar) {
|
|
return buildScalarResult(analysisType, plotName, sweepVec, dataVecs);
|
|
}
|
|
|
|
return buildVectorResult(analysisType, plotName, sweepVec, dataVecs, isComplex);
|
|
}
|
|
|
|
function readVectorData(infoPtr, readImag) {
|
|
const length = HEAP32[(infoPtr + VECTOR_INFO_LENGTH_OFFSET) >> 2];
|
|
if (length <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const realDataPtr = HEAPU32[(infoPtr + VECTOR_INFO_REALDATA_OFFSET) >> 2];
|
|
const compDataPtr = HEAPU32[(infoPtr + VECTOR_INFO_IMAGDATA_OFFSET) >> 2];
|
|
|
|
// For complex vectors (AC analysis), ngspice stores data as an array of
|
|
// ngcomplex_t structs ({double real; double imag;}) in the compdata field,
|
|
// with realdata set to NULL.
|
|
if (!realDataPtr && compDataPtr && readImag) {
|
|
const real = new Float64Array(length);
|
|
const imag = new Float64Array(length);
|
|
const baseIdx = compDataPtr >> 3; // byte offset to float64 index
|
|
for (let i = 0; i < length; i++) {
|
|
real[i] = HEAPF64[baseIdx + i * 2];
|
|
imag[i] = HEAPF64[baseIdx + i * 2 + 1];
|
|
}
|
|
return { real, imag, length };
|
|
}
|
|
|
|
if (!realDataPtr) {
|
|
return null;
|
|
}
|
|
|
|
const real = new Float64Array(length);
|
|
real.set(HEAPF64.subarray(realDataPtr >> 3, (realDataPtr >> 3) + length));
|
|
|
|
let imag = null;
|
|
if (readImag && compDataPtr) {
|
|
// If both realdata and compdata exist, compdata holds ngcomplex_t structs
|
|
imag = new Float64Array(length);
|
|
const baseIdx = compDataPtr >> 3;
|
|
for (let i = 0; i < length; i++) {
|
|
imag[i] = HEAPF64[baseIdx + i * 2 + 1];
|
|
}
|
|
}
|
|
|
|
return { real, imag, length };
|
|
}
|
|
|
|
function buildScalarResult(analysisType, plotName, sweepVec, dataVecs) {
|
|
const scalars = {};
|
|
|
|
if (sweepVec) {
|
|
for (let i = 0; i < sweepVec.length; i++) {
|
|
scalars[sweepVec.name] = sweepVec.real[0];
|
|
}
|
|
}
|
|
|
|
for (const vec of dataVecs) {
|
|
scalars[vec.name] = vec.real[0];
|
|
}
|
|
|
|
return {
|
|
type: analysisType,
|
|
sweep: null,
|
|
vectors: [],
|
|
scalars,
|
|
meta: { plotName },
|
|
};
|
|
}
|
|
|
|
function buildVectorResult(analysisType, plotName, sweepVec, dataVecs, isComplex) {
|
|
const sweep = sweepVec
|
|
? {
|
|
name: sweepVec.name,
|
|
unit: inferUnit(sweepVec.name),
|
|
values: sweepVec.real,
|
|
}
|
|
: null;
|
|
|
|
const vectors = dataVecs.map((vec) => ({
|
|
name: vec.name,
|
|
unit: inferUnit(vec.name),
|
|
real: vec.real,
|
|
imag: isComplex ? vec.imag : null,
|
|
complex: isComplex,
|
|
}));
|
|
|
|
return {
|
|
type: analysisType,
|
|
sweep,
|
|
vectors,
|
|
scalars: null,
|
|
meta: { plotName },
|
|
};
|
|
}
|
|
|
|
function detectAnalysisType(plotName) {
|
|
const match = plotName.match(/^(tran|ac|dc|op|noise|sens|tf|pz)/i);
|
|
if (match) {
|
|
return match[1].toLowerCase();
|
|
}
|
|
return 'unknown';
|
|
}
|
|
|
|
function inferUnit(vectorName) {
|
|
const lower = vectorName.toLowerCase();
|
|
if (lower === 'time') return 's';
|
|
if (lower === 'frequency') return 'Hz';
|
|
if (/^v\(/.test(lower) || /^v_/.test(lower)) return 'V';
|
|
if (/^i\(/.test(lower) || /^i_/.test(lower)) return 'A';
|
|
if (/^p\(/.test(lower)) return 'W';
|
|
return '';
|
|
}
|
|
|
|
function collectTransferables(analyses) {
|
|
const buffers = new Set();
|
|
|
|
for (const analysis of analyses) {
|
|
if (analysis.sweep) {
|
|
buffers.add(analysis.sweep.values.buffer);
|
|
}
|
|
|
|
for (const vec of analysis.vectors) {
|
|
buffers.add(vec.real.buffer);
|
|
if (vec.imag) {
|
|
buffers.add(vec.imag.buffer);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...buffers];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Netlist preprocessing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildCircuitLines(netlist) {
|
|
const lines = netlist.replace(/\r/g, '').split('\n');
|
|
const filtered = [];
|
|
let inControl = false;
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim().toLowerCase();
|
|
|
|
if (/^\.control\b/.test(trimmed)) {
|
|
inControl = true;
|
|
filtered.push(line);
|
|
continue;
|
|
}
|
|
|
|
if (/^\.endc\b/.test(trimmed)) {
|
|
inControl = false;
|
|
filtered.push(line);
|
|
continue;
|
|
}
|
|
|
|
if (inControl) {
|
|
if (/^save\b/.test(trimmed) || /^wrdt\b/.test(trimmed)) {
|
|
continue;
|
|
}
|
|
filtered.push(line);
|
|
continue;
|
|
}
|
|
|
|
if (/^\s*\.save\b/i.test(line) || /^\s*\.wrdt\b/i.test(line)) {
|
|
continue;
|
|
}
|
|
|
|
filtered.push(line);
|
|
}
|
|
|
|
const hasEnd = filtered.some((line) => /^\s*\.end\s*$/i.test(line));
|
|
if (!hasEnd) {
|
|
filtered.push('.end');
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Session & module setup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function ensureSession(config) {
|
|
await ensureModule(config);
|
|
|
|
if (!filesystemReady) {
|
|
self.postMessage({ type: 'status', requestId: config.requestId, message: 'Staging library assets…' });
|
|
await stageFilesystem();
|
|
filesystemReady = true;
|
|
}
|
|
|
|
if (!callbackPointers) {
|
|
registerCallbacks();
|
|
}
|
|
|
|
if (!ngspiceInitialized) {
|
|
initializeNgspice();
|
|
}
|
|
}
|
|
|
|
async function ensureModule(config) {
|
|
if (!moduleReady) {
|
|
moduleConfig = normalizeConfig(config);
|
|
moduleReady = new Promise((resolve, reject) => {
|
|
self.Module = {
|
|
noInitialRun: true,
|
|
locateFile: (path) => {
|
|
if (path.endsWith('.wasm')) {
|
|
return resolveAssetUrl(moduleConfig.assetBaseUrl, moduleConfig.wasmFile);
|
|
}
|
|
return path;
|
|
},
|
|
print: (text) => {
|
|
if (currentRun) {
|
|
self.postMessage({ type: 'stdout', requestId: currentRun.requestId, line: text });
|
|
}
|
|
},
|
|
printErr: (text) => {
|
|
if (currentRun && !text.includes('keepRuntimeAlive() is set')) {
|
|
self.postMessage({ type: 'stderr', requestId: currentRun.requestId, line: text });
|
|
}
|
|
},
|
|
onRuntimeInitialized: () => {
|
|
bindApi();
|
|
resolve();
|
|
},
|
|
};
|
|
|
|
try {
|
|
importScripts(resolveAssetUrl(moduleConfig.assetBaseUrl, moduleConfig.moduleScript));
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
return moduleReady;
|
|
}
|
|
|
|
function bindApi() {
|
|
api = {
|
|
init: Module.cwrap('ngSpice_Init', 'number', ['number', 'number', 'number', 'number', 'number', 'number', 'number']),
|
|
command: Module.cwrap('ngSpice_Command', 'number', ['string']),
|
|
circ: Module.cwrap('ngSpice_Circ', 'number', ['number']),
|
|
curPlot: Module.cwrap('ngSpice_CurPlot', 'string', []),
|
|
allPlots: Module.cwrap('ngSpice_AllPlots', 'number', []),
|
|
allVecs: Module.cwrap('ngSpice_AllVecs', 'number', ['string']),
|
|
getVecInfo: Module.cwrap('ngGet_Vec_Info', 'number', ['string']),
|
|
reset: Module.cwrap('ngSpice_Reset', 'number', []),
|
|
nospiceinit: Module.cwrap('ngSpice_nospiceinit', 'number', []),
|
|
setInputPath: Module.cwrap('ngCM_Input_Path', 'number', ['string']),
|
|
};
|
|
}
|
|
|
|
function registerCallbacks() {
|
|
callbackPointers = {
|
|
print: Module.addFunction(onPrint, 'iiii'),
|
|
status: Module.addFunction(onStatus, 'iiii'),
|
|
exit: Module.addFunction(onControlledExit, 'iiiiii'),
|
|
data: Module.addFunction(onData, 'iiiii'),
|
|
dataInit: Module.addFunction(onDataInit, 'iiii'),
|
|
bg: Module.addFunction(onBackground, 'iiii'),
|
|
};
|
|
}
|
|
|
|
function initializeNgspice() {
|
|
api.nospiceinit();
|
|
const rc = api.init(
|
|
callbackPointers.print,
|
|
callbackPointers.status,
|
|
callbackPointers.exit,
|
|
callbackPointers.data,
|
|
callbackPointers.dataInit,
|
|
callbackPointers.bg,
|
|
0,
|
|
);
|
|
|
|
if (rc !== 0) {
|
|
throw new Error(`ngSpice_Init failed with status ${rc}.`);
|
|
}
|
|
|
|
api.setInputPath('/');
|
|
api.command('set xspice_enabled');
|
|
api.command('source /spinit');
|
|
ngspiceInitialized = true;
|
|
}
|
|
|
|
function resetNgspice() {
|
|
if (!ngspiceInitialized || !api) {
|
|
return;
|
|
}
|
|
|
|
api.reset();
|
|
ngspiceInitialized = false;
|
|
}
|
|
|
|
async function stageFilesystem() {
|
|
ensurePath('/usr/local/lib/ngspice');
|
|
ensurePath('/usr/local/share/ngspice/scripts');
|
|
|
|
for (const [index, name] of MODEL_FILES.entries()) {
|
|
self.postMessage({
|
|
type: 'status',
|
|
message: `Loading code model ${index + 1}/${MODEL_FILES.length}: ${name}`,
|
|
});
|
|
const data = await fetchBinary(name);
|
|
FS.writeFile(`/usr/local/lib/ngspice/${name}`, new Uint8Array(data));
|
|
}
|
|
|
|
const spinitText = await fetchText('spinit');
|
|
FS.writeFile('/usr/local/share/ngspice/scripts/spinit', spinitText);
|
|
FS.writeFile('/spinit', spinitText);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ngspice callbacks (invoked during simulation)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function onPrint(messagePtr) {
|
|
const text = Module.UTF8ToString(messagePtr);
|
|
const isStderr = text.startsWith('stderr ');
|
|
const isStdout = text.startsWith('stdout ');
|
|
const body = isStderr ? text.slice(7) : isStdout ? text.slice(7) : text;
|
|
const channel = isStderr ? 'stderr' : 'stdout';
|
|
|
|
// Interactive command capture: route stdout/stderr from a command()
|
|
// call into the per-command buffer AND emit a live event.
|
|
if (activeCommandCapture) {
|
|
if (isStderr) {
|
|
activeCommandCapture.stderr.push(body);
|
|
} else {
|
|
activeCommandCapture.stdout.push(body);
|
|
}
|
|
self.postMessage({ type: channel, requestId: activeCommandCapture.requestId, line: body });
|
|
return 0;
|
|
}
|
|
|
|
// Batch run path — unchanged from the original worker.
|
|
if (currentRun) {
|
|
self.postMessage({ type: channel, requestId: currentRun.requestId, line: body });
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function onStatus(messagePtr) {
|
|
if (currentRun) {
|
|
const message = Module.UTF8ToString(messagePtr);
|
|
self.postMessage({ type: 'status', requestId: currentRun.requestId, message });
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function onControlledExit(status, immediate, fromQuit) {
|
|
if (currentRun && status !== 0 && !fromQuit) {
|
|
self.postMessage({
|
|
type: 'stderr',
|
|
requestId: currentRun.requestId,
|
|
line: `shared ngspice requested exit ${status} (immediate=${Boolean(immediate)})`,
|
|
});
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function onData(vecvaluesAllPtr, vectorCount) {
|
|
const currentTime = readCurrentTimeFromData(vecvaluesAllPtr, vectorCount);
|
|
postDebug('data-callback', {
|
|
vecvaluesAllPtr,
|
|
vectorCount,
|
|
currentTime,
|
|
timeVectorName: currentRun?.timeVectorName,
|
|
});
|
|
emitProgress(false, currentTime);
|
|
return 0;
|
|
}
|
|
|
|
function onDataInit(vecinfoAllPtr) {
|
|
if (currentRun) {
|
|
const timeVectorName = findTimeVectorName(vecinfoAllPtr);
|
|
if (timeVectorName) {
|
|
currentRun.timeVectorName = timeVectorName;
|
|
}
|
|
postDebug('data-init-callback', {
|
|
vecinfoAllPtr,
|
|
timeVectorName: currentRun.timeVectorName,
|
|
});
|
|
}
|
|
|
|
if (currentRun) {
|
|
self.postMessage({ type: 'status', requestId: currentRun.requestId, message: 'Transient vectors initialized.' });
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function onBackground() {
|
|
return 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Progress tracking helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function emitProgress(force, currentTimeOverride = null) {
|
|
if (!currentRun || !currentRun.finalTime || currentRun.finalTime <= 0) {
|
|
return;
|
|
}
|
|
|
|
const currentTime = currentTimeOverride ?? readLatestVectorValue(currentRun.timeVectorName || 'time');
|
|
if (currentTime === null) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const progress = Math.min(Math.max(currentTime / currentRun.finalTime, currentRun.lastProgress), 1);
|
|
if (!force && progress - currentRun.lastProgress < 0.001 && now - currentRun.lastEmitAt < 80) {
|
|
return;
|
|
}
|
|
|
|
currentRun.lastProgress = progress;
|
|
currentRun.lastCurrentTime = currentTime;
|
|
currentRun.lastEmitAt = now;
|
|
self.postMessage({
|
|
type: 'progress',
|
|
requestId: currentRun.requestId,
|
|
currentTime,
|
|
finalTime: currentRun.finalTime,
|
|
progress,
|
|
});
|
|
}
|
|
|
|
function readLatestVectorValue(vectorName) {
|
|
let vectorInfoPtr = api.getVecInfo(vectorName);
|
|
if (!vectorInfoPtr && !vectorName.includes('.')) {
|
|
const currentPlot = api.curPlot?.();
|
|
if (currentPlot) {
|
|
vectorInfoPtr = api.getVecInfo(`${currentPlot}.${vectorName}`);
|
|
}
|
|
}
|
|
|
|
if (!vectorInfoPtr) {
|
|
return null;
|
|
}
|
|
|
|
const realDataPtr = HEAPU32[(vectorInfoPtr + VECTOR_INFO_REALDATA_OFFSET) >> 2];
|
|
const length = HEAP32[(vectorInfoPtr + VECTOR_INFO_LENGTH_OFFSET) >> 2];
|
|
if (!realDataPtr || length <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return HEAPF64[(realDataPtr >> 3) + length - 1];
|
|
}
|
|
|
|
function readCurrentTimeFromData(vecvaluesAllPtr, vectorCount) {
|
|
if (!vecvaluesAllPtr) {
|
|
return null;
|
|
}
|
|
|
|
const count = vectorCount || HEAP32[(vecvaluesAllPtr + VECVALUESALL_COUNT_OFFSET) >> 2];
|
|
const valuesPtr = HEAPU32[(vecvaluesAllPtr + VECVALUESALL_VALUES_OFFSET) >> 2];
|
|
if (!valuesPtr || count <= 0) {
|
|
return null;
|
|
}
|
|
|
|
for (let index = 0; index < count; index += 1) {
|
|
const vecvaluePtr = HEAPU32[(valuesPtr >> 2) + index];
|
|
if (!vecvaluePtr) {
|
|
continue;
|
|
}
|
|
|
|
const namePtr = HEAPU32[(vecvaluePtr + VECVALUES_NAME_OFFSET) >> 2];
|
|
const name = namePtr ? Module.UTF8ToString(namePtr) : '';
|
|
const isScale = HEAPU8[vecvaluePtr + VECVALUES_IS_SCALE_OFFSET] !== 0;
|
|
if (!isScale && name !== 'time' && name !== currentRun?.timeVectorName) {
|
|
continue;
|
|
}
|
|
|
|
return HEAPF64[(vecvaluePtr + VECVALUES_REAL_OFFSET) >> 3];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function findTimeVectorName(vecinfoAllPtr) {
|
|
if (!vecinfoAllPtr) {
|
|
return null;
|
|
}
|
|
|
|
const count = HEAP32[(vecinfoAllPtr + VECINFOALL_COUNT_OFFSET) >> 2];
|
|
const vecsPtr = HEAPU32[(vecinfoAllPtr + VECINFOALL_VECS_OFFSET) >> 2];
|
|
if (!vecsPtr || count <= 0) {
|
|
return null;
|
|
}
|
|
|
|
for (let index = 0; index < count; index += 1) {
|
|
const vecinfoPtr = HEAPU32[(vecsPtr >> 2) + index];
|
|
if (!vecinfoPtr) {
|
|
continue;
|
|
}
|
|
|
|
const namePtr = HEAPU32[(vecinfoPtr + VECINFO_NAME_OFFSET) >> 2];
|
|
if (!namePtr) {
|
|
continue;
|
|
}
|
|
|
|
const name = Module.UTF8ToString(namePtr);
|
|
if (name === 'time') {
|
|
return name;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Netlist parsing helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function extractTranFinalTime(netlist) {
|
|
for (const line of netlist.split(/\r?\n/)) {
|
|
if (!/^\s*\.?tran\b/i.test(line)) {
|
|
continue;
|
|
}
|
|
|
|
const tokens = line.trim().split(/\s+/);
|
|
if (tokens.length < 3) {
|
|
postDebug('tran-parse-skipped', { line, reason: 'too-few-tokens' });
|
|
return null;
|
|
}
|
|
|
|
const finalTime = parseScaledNumber(tokens[2]);
|
|
postDebug('tran-parse-result', { line, tokens, finalTime });
|
|
return finalTime;
|
|
}
|
|
|
|
postDebug('tran-parse-missed', { reason: 'no-tran-line-found' });
|
|
return null;
|
|
}
|
|
|
|
function parseScaledNumber(token) {
|
|
const match = token.trim().match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)([a-zA-Z]+)?$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const value = Number(match[1]);
|
|
const suffix = (match[2] || '').toLowerCase();
|
|
const multipliers = {
|
|
t: 1e12,
|
|
g: 1e9,
|
|
meg: 1e6,
|
|
k: 1e3,
|
|
m: 1e-3,
|
|
u: 1e-6,
|
|
n: 1e-9,
|
|
p: 1e-12,
|
|
f: 1e-15,
|
|
};
|
|
|
|
if (!suffix) {
|
|
return value;
|
|
}
|
|
|
|
return value * (multipliers[suffix] || 1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Utility: memory allocation, URLs, filesystem
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function allocateCStringArray(lines) {
|
|
const pointerSize = 4;
|
|
const stringPointers = lines.map((line) => allocateCString(line));
|
|
const arrayPointer = _malloc((stringPointers.length + 1) * pointerSize);
|
|
|
|
stringPointers.forEach((pointer, index) => {
|
|
HEAPU32[(arrayPointer >> 2) + index] = pointer;
|
|
});
|
|
HEAPU32[(arrayPointer >> 2) + stringPointers.length] = 0;
|
|
|
|
return { arrayPointer, stringPointers };
|
|
}
|
|
|
|
function freeCStringArray({ arrayPointer, stringPointers }) {
|
|
stringPointers.forEach((pointer) => _free(pointer));
|
|
_free(arrayPointer);
|
|
}
|
|
|
|
function allocateCString(value) {
|
|
const length = Module.lengthBytesUTF8(value) + 1;
|
|
const pointer = _malloc(length);
|
|
Module.stringToUTF8(value, pointer, length);
|
|
return pointer;
|
|
}
|
|
|
|
function normalizeConfig(config) {
|
|
return {
|
|
assetBaseUrl: config.assetBaseUrl || './',
|
|
moduleScript: config.moduleScript || 'ngspice-lib.js',
|
|
wasmFile: config.wasmFile || 'ngspice-lib.wasm',
|
|
};
|
|
}
|
|
|
|
function resolveAssetUrl(basePath, fileName) {
|
|
return new URL(`${trimTrailingSlash(basePath)}/${fileName}`, self.location.href).toString();
|
|
}
|
|
|
|
function trimTrailingSlash(value) {
|
|
return value.replace(/\/$/, '');
|
|
}
|
|
|
|
async function fetchBinary(fileName) {
|
|
const response = await fetch(resolveAssetUrl(moduleConfig.assetBaseUrl, fileName));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch ${fileName}: ${response.status} ${response.statusText}`);
|
|
}
|
|
return response.arrayBuffer();
|
|
}
|
|
|
|
async function fetchText(fileName) {
|
|
const response = await fetch(resolveAssetUrl(moduleConfig.assetBaseUrl, fileName));
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch ${fileName}: ${response.status} ${response.statusText}`);
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
function ensurePath(path) {
|
|
const parts = path.split('/').filter(Boolean);
|
|
let current = '/';
|
|
for (const part of parts) {
|
|
FS.createPath(current, part, true, true);
|
|
current = current === '/' ? `/${part}` : `${current}/${part}`;
|
|
}
|
|
}
|