149 lines
5.1 KiB
TypeScript
149 lines
5.1 KiB
TypeScript
/**
|
|
* NgSpiceWorkerAdapter — production SolverPort implementation.
|
|
*
|
|
* Wraps the vendored `NgSpiceInteractive` client (which itself wraps a
|
|
* Web Worker that loads the ngspice-interactive WASM build). The
|
|
* adapter translates between the domain `SolverPort` types and the
|
|
* client's message-passing semantics.
|
|
*
|
|
* Single responsibility: this file is the *only* place in the codebase
|
|
* that knows about Web Workers, the ngspice command syntax, or vector
|
|
* lookup details. If we ever swap engines (XSpice fork, native build,
|
|
* Falstad-style custom engine), the work is contained here.
|
|
*
|
|
* Concurrency: the underlying worker serialises commands itself, so
|
|
* the adapter can fire many `readVec` calls in parallel during a
|
|
* solve and they queue in the worker. The adapter awaits all of them
|
|
* before returning a `SolveResult`.
|
|
*/
|
|
import { NgSpiceInteractive } from '../wasm/NgSpiceInteractive';
|
|
import type {
|
|
SolverPort,
|
|
SolveAnalysis,
|
|
SolveResult,
|
|
SolveOptions,
|
|
SolveVector,
|
|
} from '../ports/SolverPort';
|
|
|
|
/**
|
|
* Build the ngspice command string for a given analysis. All formats
|
|
* follow chapter 17 of the ngspice manual.
|
|
*/
|
|
function analysisToCommand(analysis: SolveAnalysis): string {
|
|
switch (analysis.kind) {
|
|
case 'op':
|
|
return 'op';
|
|
case 'tran':
|
|
// ngspice tran syntax: `tran <tstep> <tstop> [<tstart> [<tmax>] [uic]]`
|
|
return `tran ${analysis.step} ${analysis.stop}`;
|
|
case 'ac':
|
|
// ngspice ac syntax: `ac <sweep> <points> <fstart> <fstop>`
|
|
return `ac ${analysis.sweep} ${analysis.points} ${analysis.fstart} ${analysis.fstop}`;
|
|
}
|
|
}
|
|
|
|
export class NgSpiceWorkerAdapter implements SolverPort {
|
|
private readonly client: NgSpiceInteractive;
|
|
private initialised = false;
|
|
private initPromise: Promise<void> | null = null;
|
|
|
|
constructor(client?: NgSpiceInteractive) {
|
|
this.client = client ?? new NgSpiceInteractive();
|
|
}
|
|
|
|
async init(): Promise<void> {
|
|
if (this.initialised) return;
|
|
if (!this.initPromise) {
|
|
this.initPromise = this.client.init().then(async () => {
|
|
// Convergence helpers — relaxed gmin lets op-amp + diode
|
|
// circuits bias correctly without each user netlist needing
|
|
// its own `.option`. Method=gear maxord=2 stabilises stiff
|
|
// transient solves involving B-source clamps and reactive
|
|
// networks. Mirrors NgSpiceNodeAdapter.initialiseNgspice so
|
|
// production and tests run with identical solver tolerances.
|
|
try {
|
|
await this.client.command('set noaskquit');
|
|
await this.client.command(
|
|
'option gmin=1e-10 gminsteps=20 sourcesteps=10 method=gear maxord=2',
|
|
);
|
|
} catch {
|
|
// Ignore: the build always supports these options. If the
|
|
// command path is dead, the actual solve will fail loudly
|
|
// later anyway.
|
|
}
|
|
this.initialised = true;
|
|
});
|
|
}
|
|
return this.initPromise;
|
|
}
|
|
|
|
async loadCircuit(netlist: string): Promise<void> {
|
|
await this.init();
|
|
// Drop the previous circuit deck so leftover state doesn't leak
|
|
// into the new solve. Mirrors the Node adapter's loadCircuit.
|
|
try {
|
|
await this.client.command('remcirc');
|
|
} catch {
|
|
// No previous circuit — ignore.
|
|
}
|
|
await this.client.loadNetlist(netlist);
|
|
}
|
|
|
|
async solve(analysis: SolveAnalysis, options: SolveOptions): Promise<SolveResult> {
|
|
await this.init();
|
|
const t0 = performance.now();
|
|
|
|
const cmdResult = await this.client.command(analysisToCommand(analysis));
|
|
// ngspice writes convergence warnings to stderr; surface them so
|
|
// the caller can decide to retry with relaxed options.
|
|
const warnings = cmdResult.stderr.filter((l) => l.length > 0);
|
|
|
|
// Parallel read of every requested vector. The worker serialises
|
|
// them internally, so this still costs O(N · 100µs) wall-time, but
|
|
// it avoids the await-each-then-await-next ping-pong.
|
|
const vectors = new Map<string, SolveVector>();
|
|
const reads = await Promise.allSettled(
|
|
options.vectorsOfInterest.map(async (name) => {
|
|
const v = await this.client.readVec(name);
|
|
return { requested: name, vec: v };
|
|
}),
|
|
);
|
|
|
|
let timeAxis: Float64Array = new Float64Array(0);
|
|
for (const r of reads) {
|
|
if (r.status !== 'fulfilled') continue;
|
|
const { requested, vec } = r.value;
|
|
vectors.set(requested.toLowerCase(), {
|
|
name: requested.toLowerCase(),
|
|
real: vec.real,
|
|
imag: vec.imag,
|
|
});
|
|
}
|
|
|
|
// For .tran, fetch the time axis separately. ngspice always names
|
|
// it `time` regardless of analysis name.
|
|
if (analysis.kind === 'tran') {
|
|
try {
|
|
const t = await this.client.readVec('time');
|
|
timeAxis = t.real;
|
|
} catch {
|
|
// No time vector — shouldn't happen but tolerate it.
|
|
}
|
|
}
|
|
|
|
const solveMs = performance.now() - t0;
|
|
return { analysis, vectors, timeAxis, solveMs, warnings };
|
|
}
|
|
|
|
async alterSource(name: string, dcValue: number): Promise<void> {
|
|
await this.init();
|
|
await this.client.alter(name, dcValue);
|
|
}
|
|
|
|
dispose(): void {
|
|
this.client.dispose();
|
|
this.initialised = false;
|
|
this.initPromise = null;
|
|
}
|
|
}
|