diff --git a/frontend/src/__tests__/solver-port-contract.test.ts b/frontend/src/__tests__/solver-port-contract.test.ts new file mode 100644 index 00000000..c8383c62 --- /dev/null +++ b/frontend/src/__tests__/solver-port-contract.test.ts @@ -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); + }); +}); diff --git a/frontend/src/simulation/spice/adapters/FakeSolverAdapter.ts b/frontend/src/simulation/spice/adapters/FakeSolverAdapter.ts new file mode 100644 index 00000000..6f5e4839 --- /dev/null +++ b/frontend/src/simulation/spice/adapters/FakeSolverAdapter.ts @@ -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; + +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 { + this.calls.init++; + } + + async loadCircuit(netlist: string): Promise { + this.calls.loadCircuit.push(netlist); + } + + async solve(analysis: SolveAnalysis, options: SolveOptions): Promise { + 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(); + 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 { + 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 }; + } +} diff --git a/frontend/src/simulation/spice/adapters/NgSpiceWorkerAdapter.ts b/frontend/src/simulation/spice/adapters/NgSpiceWorkerAdapter.ts new file mode 100644 index 00000000..7ee07d90 --- /dev/null +++ b/frontend/src/simulation/spice/adapters/NgSpiceWorkerAdapter.ts @@ -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 [ [] [uic]]` + return `tran ${analysis.step} ${analysis.stop}`; + case 'ac': + // ngspice ac syntax: `ac ` + 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 | null = null; + + constructor(client?: NgSpiceInteractive) { + this.client = client ?? new NgSpiceInteractive(); + } + + async init(): Promise { + if (this.initialised) return; + if (!this.initPromise) { + this.initPromise = this.client.init().then(() => { + this.initialised = true; + }); + } + return this.initPromise; + } + + async loadCircuit(netlist: string): Promise { + await this.init(); + await this.client.loadNetlist(netlist); + } + + async solve(analysis: SolveAnalysis, options: SolveOptions): Promise { + 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(); + 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 { + await this.init(); + await this.client.alter(name, dcValue); + } + + dispose(): void { + this.client.dispose(); + this.initialised = false; + this.initPromise = null; + } +} diff --git a/frontend/src/simulation/spice/ports/SolverPort.ts b/frontend/src/simulation/spice/ports/SolverPort.ts index ea4cb764..4688ce0a 100644 --- a/frontend/src/simulation/spice/ports/SolverPort.ts +++ b/frontend/src/simulation/spice/ports/SolverPort.ts @@ -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; loadCircuit(netlist: string): Promise; - solve(analysis: SolveAnalysis): Promise; + solve(analysis: SolveAnalysis, options: SolveOptions): Promise; alterSource(name: string, dcValue: number): Promise; 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 | undefined;