feat(sim): Phase 1c A2+A3 — NgSpiceWorkerAdapter + FakeSolverAdapter

Two SolverPort adapters land in this commit:

- NgSpiceWorkerAdapter — production. Wraps the vendored
  NgSpiceInteractive client. Translates SolverPort calls into worker
  messages. Parallel readVec for every vectorOfInterest after each
  solve. .tran also reads the `time` vector for the axis.
- FakeSolverAdapter — in-memory test double. Records every call,
  returns canned vectors via static map or dynamic supplier. Optional
  solveDelayMs for race-condition tests.

Port surface refined: solve(analysis, options) now takes
SolveOptions.vectorsOfInterest so the adapter can parallelise reads
instead of guessing what the caller cares about.

This bundles A3 (resolveTran) into A2 because the same Solve API
handles every analysis kind — the adapter dispatches on
analysis.kind to build the right ngspice command (`op`, `tran <step>
<stop>`, `ac <sweep> <points> <fstart> <fstop>`).

11 SolverPort contract tests pass. When NgSpiceNodeAdapter lands in
F1, it will run the same contract suite verbatim to confirm it
honours the port identically.

Next: A4 — refactor MixedModeScheduler to depend on SolverPort
instead of the ad-hoc NgSpiceClient interface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-15 19:21:46 +02:00
parent e8537eec6f
commit 834f8f7e0a
4 changed files with 388 additions and 14 deletions

View File

@ -0,0 +1,137 @@
/**
* Contract tests for the SolverPort interface.
*
* Any adapter (real or fake) must satisfy these invariants. Today
* `FakeSolverAdapter` is the SUT; once `NgSpiceNodeAdapter` lands
* (sub-step F1) it will run the same suite to confirm it honours the
* port contract exactly the same way.
*
* Contract:
* - init() is idempotent
* - loadCircuit() can be called multiple times (replaces previous)
* - solve() returns a SolveResult with vectors keyed by lower-case name
* - solve() ignores requested vectors that aren't in the engine state
* - alterSource() doesn't return data
* - dispose() is the final call; subsequent uses are caller's bug
*/
import { describe, it, expect } from 'vitest';
import { FakeSolverAdapter } from '../simulation/spice/adapters/FakeSolverAdapter';
describe('SolverPort contract — FakeSolverAdapter', () => {
it('init() is idempotent and counted per call', async () => {
const fake = new FakeSolverAdapter();
await fake.init();
await fake.init();
await fake.init();
expect(fake.calls.init).toBe(3);
});
it('loadCircuit() records every submission', async () => {
const fake = new FakeSolverAdapter();
await fake.loadCircuit('first netlist');
await fake.loadCircuit('second netlist');
expect(fake.calls.loadCircuit).toEqual(['first netlist', 'second netlist']);
});
it('solve() returns SolveResult with requested vectors', async () => {
const fake = new FakeSolverAdapter({
vectors: { 'v(n0)': 4.97, 'v(n1)': 0.1, 'i(v_sense)': 0.005 },
});
const result = await fake.solve(
{ kind: 'op' },
{ vectorsOfInterest: ['v(n0)', 'v(n1)', 'i(v_sense)'] },
);
expect(result.analysis).toEqual({ kind: 'op' });
expect(result.vectors.get('v(n0)')?.real[0]).toBeCloseTo(4.97);
expect(result.vectors.get('v(n1)')?.real[0]).toBeCloseTo(0.1);
expect(result.vectors.get('i(v_sense)')?.real[0]).toBeCloseTo(0.005);
expect(result.warnings).toEqual([]);
});
it('solve() omits requested vectors that are missing from engine state', async () => {
const fake = new FakeSolverAdapter({ vectors: { 'v(present)': 1.5 } });
const result = await fake.solve(
{ kind: 'op' },
{ vectorsOfInterest: ['v(present)', 'v(missing)'] },
);
expect(result.vectors.has('v(present)')).toBe(true);
expect(result.vectors.has('v(missing)')).toBe(false);
});
it('solve() normalises vector names to lower-case', async () => {
const fake = new FakeSolverAdapter({ vectors: { 'v(out)': 3.3 } });
const result = await fake.solve({ kind: 'op' }, { vectorsOfInterest: ['V(OUT)'] });
expect(result.vectors.has('v(out)')).toBe(true);
expect(result.vectors.has('V(OUT)')).toBe(false);
});
it('solve() honours dynamic vector supplier', async () => {
let voltage = 1.0;
const fake = new FakeSolverAdapter({
vectors: () => ({ 'v(out)': voltage }),
});
const r1 = await fake.solve({ kind: 'op' }, { vectorsOfInterest: ['v(out)'] });
expect(r1.vectors.get('v(out)')?.real[0]).toBeCloseTo(1.0);
voltage = 4.5;
const r2 = await fake.solve({ kind: 'op' }, { vectorsOfInterest: ['v(out)'] });
expect(r2.vectors.get('v(out)')?.real[0]).toBeCloseTo(4.5);
});
it('alterSource() records calls and invokes onAlter hook', async () => {
const fake = new FakeSolverAdapter();
let lastAlter: [string, number] | null = null;
fake.onAlter = (name, value) => {
lastAlter = [name, value];
};
await fake.alterSource('V_uno_9', 5);
expect(fake.calls.alterSource).toEqual([['V_uno_9', 5]]);
expect(lastAlter).toEqual(['V_uno_9', 5]);
});
it('exposes a .tran-shaped result when requested', async () => {
const fake = new FakeSolverAdapter({
vectors: { 'v(out)': new Float64Array([0, 1, 2, 3, 4]) },
timeAxis: new Float64Array([0, 1e-4, 2e-4, 3e-4, 4e-4]),
});
const result = await fake.solve(
{ kind: 'tran', step: '1e-4', stop: '4e-4' },
{ vectorsOfInterest: ['v(out)'] },
);
expect(result.timeAxis.length).toBe(5);
expect(result.vectors.get('v(out)')?.real.length).toBe(5);
expect(result.vectors.get('v(out)')?.real[3]).toBe(3);
});
it('solve records all calls with the analysis + vectorsOfInterest', async () => {
const fake = new FakeSolverAdapter();
await fake.solve({ kind: 'op' }, { vectorsOfInterest: ['v(a)'] });
await fake.solve(
{ kind: 'tran', step: '1u', stop: '10m' },
{ vectorsOfInterest: ['v(b)', 'i(v1)'] },
);
expect(fake.calls.solve).toEqual([
{ analysis: { kind: 'op' }, vectorsOfInterest: ['v(a)'] },
{
analysis: { kind: 'tran', step: '1u', stop: '10m' },
vectorsOfInterest: ['v(b)', 'i(v1)'],
},
]);
});
it('respects solveDelayMs (race-condition tests can use this)', async () => {
const fake = new FakeSolverAdapter({ solveDelayMs: 50 });
const t0 = performance.now();
await fake.solve({ kind: 'op' }, { vectorsOfInterest: [] });
expect(performance.now() - t0).toBeGreaterThanOrEqual(45);
});
it('dispose() is counted; resetCalls() clears the log', async () => {
const fake = new FakeSolverAdapter();
fake.dispose();
fake.dispose();
expect(fake.calls.dispose).toBe(2);
fake.resetCalls();
expect(fake.calls.dispose).toBe(0);
expect(fake.calls.init).toBe(0);
});
});

View File

@ -0,0 +1,103 @@
/**
* FakeSolverAdapter — in-memory SolverPort for unit tests.
*
* Records every call so tests can assert against the call log, and
* returns canned vector values per a configurable map. No SPICE
* engine, no WASM, no Worker — fast enough to run thousands of times
* in a single test file.
*
* Two ways to script it:
*
* 1. Static voltages
* const fake = new FakeSolverAdapter({
* vectors: { 'v(net_drain)': 4.97, 'v(net_gate)': 0.1 },
* });
*
* 2. Dynamic — supplier function recomputed on every solve
* const fake = new FakeSolverAdapter({
* vectors: () => ({ 'v(out)': vinTracker.current }),
* });
* ...
* fake.onAlter = (name, value) => {
* if (name === 'V_uno_9') vinTracker.current = value;
* };
*/
import type {
SolverPort,
SolveAnalysis,
SolveResult,
SolveOptions,
SolveVector,
} from '../ports/SolverPort';
type VectorMap = Record<string, number | Float64Array>;
interface FakeSolverConfig {
/** Static map OR a supplier called on every solve. */
vectors?: VectorMap | (() => VectorMap);
/** Optional time axis for .tran results. */
timeAxis?: Float64Array;
/** Optional latency for `solve` in ms (for testing race conditions). */
solveDelayMs?: number;
}
export class FakeSolverAdapter implements SolverPort {
/** Call log — tests assert against this. */
public calls: {
init: number;
loadCircuit: string[];
solve: Array<{ analysis: SolveAnalysis; vectorsOfInterest: readonly string[] }>;
alterSource: Array<[string, number]>;
dispose: number;
} = { init: 0, loadCircuit: [], solve: [], alterSource: [], dispose: 0 };
/** Hook for tests that want to react to an alter (e.g. update canned vectors). */
public onAlter: ((name: string, dcValue: number) => void) | null = null;
constructor(private readonly config: FakeSolverConfig = {}) {}
async init(): Promise<void> {
this.calls.init++;
}
async loadCircuit(netlist: string): Promise<void> {
this.calls.loadCircuit.push(netlist);
}
async solve(analysis: SolveAnalysis, options: SolveOptions): Promise<SolveResult> {
this.calls.solve.push({ analysis, vectorsOfInterest: options.vectorsOfInterest });
if (this.config.solveDelayMs) {
await new Promise((r) => setTimeout(r, this.config.solveDelayMs));
}
const raw = typeof this.config.vectors === 'function' ? this.config.vectors() : this.config.vectors ?? {};
const vectors = new Map<string, SolveVector>();
for (const requested of options.vectorsOfInterest) {
const key = requested.toLowerCase();
if (!(key in raw)) continue;
const value = raw[key];
const real = typeof value === 'number' ? new Float64Array([value]) : value;
vectors.set(key, { name: key, real, imag: null });
}
return {
analysis,
vectors,
timeAxis: this.config.timeAxis ?? new Float64Array(0),
solveMs: 0,
warnings: [],
};
}
async alterSource(name: string, dcValue: number): Promise<void> {
this.calls.alterSource.push([name, dcValue]);
if (this.onAlter) this.onAlter(name, dcValue);
}
dispose(): void {
this.calls.dispose++;
}
/** Test helper — reset all call counters without disposing. */
resetCalls(): void {
this.calls = { init: 0, loadCircuit: [], solve: [], alterSource: [], dispose: 0 };
}
}

View File

@ -0,0 +1,125 @@
/**
* 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(() => {
this.initialised = true;
});
}
return this.initPromise;
}
async loadCircuit(netlist: string): Promise<void> {
await this.init();
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;
}
}

View File

@ -38,9 +38,13 @@ export interface SolveVector {
}
/**
* Result of a complete solve. Includes every vector the engine
* computed; consumers index by lower-case name (e.g. `v(n2)` for the
* voltage at net `n2`).
* Result of a complete solve. Includes every requested vector.
* Consumers index by lower-case name (e.g. `v(n2)` for the voltage
* at net `n2`).
*
* Vectors not in the request (or that don't exist in the engine
* state) are simply absent from the map. Callers must tolerate
* missing vectors — a disconnected pin won't produce one.
*/
export interface SolveResult {
analysis: SolveAnalysis;
@ -57,6 +61,21 @@ export interface SolveResult {
warnings: string[];
}
/**
* What to ask the engine for after a solve. Listing vectors up
* front lets the adapter parallelise reads (a Worker round-trip per
* vector is O(100µs); doing them concurrently is a real win for
* netlists with 50+ nets).
*/
export interface SolveOptions {
/**
* Vector names to read. Lower-case, ngspice-formatted
* (`v(node_name)` for voltages, `i(v_source_name)` for branch
* currents). Empty array = no vectors read (only timing).
*/
vectorsOfInterest: readonly string[];
}
/**
* The port itself. An adapter (real WASM, fake, etc.) implements
* this; domain code accepts an instance via constructor injection.
@ -79,17 +98,7 @@ export interface SolveResult {
export interface SolverPort {
init(): Promise<void>;
loadCircuit(netlist: string): Promise<void>;
solve(analysis: SolveAnalysis): Promise<SolveResult>;
solve(analysis: SolveAnalysis, options: SolveOptions): Promise<SolveResult>;
alterSource(name: string, dcValue: number): Promise<void>;
dispose(): void;
}
/**
* The set of vector names a caller is interested in. Adapters MAY
* optimise by only fetching these from the engine, but they're
* allowed to return more — the caller filters on read.
*
* Reserved for future optimisation: not currently honoured by any
* adapter, but the contract leaves the door open.
*/
export type VectorFilter = ReadonlySet<string> | undefined;