diff --git a/frontend/src/__tests__/circuit-simulation-service.test.ts b/frontend/src/__tests__/circuit-simulation-service.test.ts new file mode 100644 index 00000000..a5d14d7b --- /dev/null +++ b/frontend/src/__tests__/circuit-simulation-service.test.ts @@ -0,0 +1,276 @@ +/** + * CircuitSimulationService tests. + * + * Fully isolated from useSimulatorStore + useElectricalStore + the + * WASM scheduler. Uses fakes for every port so the service's + * orchestration logic is exercised standalone. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + CircuitSimulationService, + type SimulatorStorePort, + type ElectricalStorePort, + type MixedModeSchedulerPort, + type ElectricalSnapshot, +} from '../simulation/spice/CircuitSimulationService'; +import { + getMixedModeScheduler, + __resetMixedModeScheduler, + __setSchedulerSolverFactoryForTests, +} from '../simulation/spice/MixedModeScheduler'; +import { FakeSolverAdapter } from '../simulation/spice/adapters/FakeSolverAdapter'; + +afterEach(() => { + __resetMixedModeScheduler(); +}); + +function makeSimStore(initial: { + components: Array<{ id: string; metadataId: string; properties: Record }>; + wires: Array<{ + id: string; + start: { componentId: string; pinName: string }; + end: { componentId: string; pinName: string }; + }>; + boards: Array<{ id: string; boardKind: string }>; +}): { port: SimulatorStorePort; set(next: Partial): void } { + let state: typeof initial = initial; + const listeners: Array<(s: unknown, p: unknown) => void> = []; + return { + port: { + getState: () => state, + subscribe(l) { + listeners.push(l); + return () => { + const i = listeners.indexOf(l); + if (i >= 0) listeners.splice(i, 1); + }; + }, + }, + set(next) { + const prev = state; + state = { ...state, ...next }; + for (const l of listeners) l(state, prev); + }, + }; +} + +function makeElectricalStore(): { + port: ElectricalStorePort; + snapshots: ElectricalSnapshot[]; +} { + const snapshots: ElectricalSnapshot[] = []; + return { + snapshots, + port: { + publish(s) { + snapshots.push(s); + }, + }, + }; +} + +const simpleBoardWithBoard = { + components: [{ id: 'r1', metadataId: 'resistor', properties: { value: '1k' } }], + wires: [ + { + id: 'w1', + start: { componentId: 'uno', pinName: '5V' }, + end: { componentId: 'r1', pinName: '1' }, + }, + { + id: 'w2', + start: { componentId: 'r1', pinName: '2' }, + end: { componentId: 'uno', pinName: 'GND' }, + }, + ], + boards: [{ id: 'uno', boardKind: 'arduino-uno' }], +}; + +describe('CircuitSimulationService — orchestration', () => { + it('runs an initial solve when started', async () => { + const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } }); + __setSchedulerSolverFactoryForTests(() => fake); + const sim = makeSimStore(simpleBoardWithBoard); + const elec = makeElectricalStore(); + const service = new CircuitSimulationService( + sim.port, + elec.port, + getMixedModeScheduler() as unknown as MixedModeSchedulerPort, + { collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) }, + ); + service.start(); + await new Promise((r) => setTimeout(r, 20)); + + expect(fake.calls.loadCircuit.length).toBe(1); + expect(fake.calls.solve.length).toBe(1); + expect(elec.snapshots.length).toBe(1); + expect(elec.snapshots[0]?.analysisMode).toBe('op'); + expect(elec.snapshots[0]?.nodeVoltages.vcc_rail).toBeCloseTo(5); + }); + + it('extracts branch currents from i(v_*) vectors', async () => { + const fake = new FakeSolverAdapter({ + vectors: { 'v(vcc_rail)': 5, 'i(v_vcc_rail)': -0.005 }, + }); + __setSchedulerSolverFactoryForTests(() => fake); + const sim = makeSimStore(simpleBoardWithBoard); + const elec = makeElectricalStore(); + const service = new CircuitSimulationService( + sim.port, + elec.port, + getMixedModeScheduler() as unknown as MixedModeSchedulerPort, + { collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) }, + ); + service.start(); + await new Promise((r) => setTimeout(r, 20)); + + const snap = elec.snapshots[0]; + expect(snap?.branchCurrents.v_vcc_rail).toBeCloseTo(-0.005); + }); + + it('re-solves on components / wires / boards changes', async () => { + const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } }); + __setSchedulerSolverFactoryForTests(() => fake); + const sim = makeSimStore(simpleBoardWithBoard); + const elec = makeElectricalStore(); + const service = new CircuitSimulationService( + sim.port, + elec.port, + getMixedModeScheduler() as unknown as MixedModeSchedulerPort, + { collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) }, + ); + service.start(); + await new Promise((r) => setTimeout(r, 10)); + expect(fake.calls.solve.length).toBe(1); + + sim.set({ components: [...simpleBoardWithBoard.components, { id: 'r2', metadataId: 'resistor', properties: {} }] }); + await new Promise((r) => setTimeout(r, 10)); + expect(fake.calls.solve.length).toBe(2); + }); + + it('does NOT re-solve when an unrelated field changes', async () => { + const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } }); + __setSchedulerSolverFactoryForTests(() => fake); + const sim = makeSimStore(simpleBoardWithBoard); + const elec = makeElectricalStore(); + const service = new CircuitSimulationService( + sim.port, + elec.port, + getMixedModeScheduler() as unknown as MixedModeSchedulerPort, + { collectBoardPinStates: () => ({}) }, + ); + service.start(); + await new Promise((r) => setTimeout(r, 10)); + sim.set({}); // same arrays — should NOT trigger a re-solve + await new Promise((r) => setTimeout(r, 10)); + expect(fake.calls.solve.length).toBe(1); + }); + + it('coalesces solves when one is in flight', async () => { + const fake = new FakeSolverAdapter({ + vectors: { 'v(vcc_rail)': 5 }, + solveDelayMs: 30, + }); + __setSchedulerSolverFactoryForTests(() => fake); + const sim = makeSimStore(simpleBoardWithBoard); + const elec = makeElectricalStore(); + const service = new CircuitSimulationService( + sim.port, + elec.port, + getMixedModeScheduler() as unknown as MixedModeSchedulerPort, + { collectBoardPinStates: () => ({}) }, + ); + service.start(); + // While initial solve runs, fire 3 store changes — should coalesce + // into 1 trailing solve. + sim.set({ components: [{ id: 'a', metadataId: 'resistor', properties: {} }] }); + sim.set({ components: [{ id: 'b', metadataId: 'resistor', properties: {} }] }); + sim.set({ components: [{ id: 'c', metadataId: 'resistor', properties: {} }] }); + await new Promise((r) => setTimeout(r, 100)); + expect(fake.calls.solve.length).toBe(2); // initial + 1 trailing + }); + + it('publishes .tran waveforms when analysis is transient', async () => { + const fake = new FakeSolverAdapter({ + vectors: { + 'v(n_out)': new Float64Array([0, 1, 2, 3, 4]), + 'i(v_src)': new Float64Array([0.01, 0.02, 0.03, 0.04, 0.05]), + }, + timeAxis: new Float64Array([0, 1e-4, 2e-4, 3e-4, 4e-4]), + }); + __setSchedulerSolverFactoryForTests(() => fake); + const sim = makeSimStore({ + components: [ + // A signal-generator forces .tran in buildInputFromStore. + { + id: 'sg1', + metadataId: 'signal-generator', + properties: { waveform: 'sine', frequency: 100 }, + }, + ], + wires: [], + boards: [{ id: 'uno', boardKind: 'arduino-uno' }], + }); + const elec = makeElectricalStore(); + const service = new CircuitSimulationService( + sim.port, + elec.port, + getMixedModeScheduler() as unknown as MixedModeSchedulerPort, + { collectBoardPinStates: () => ({}) }, + ); + service.start(); + await new Promise((r) => setTimeout(r, 20)); + + const snap = elec.snapshots[0]; + expect(snap?.analysisMode).toBe('tran'); + expect(snap?.timeWaveforms).toBeDefined(); + expect(snap?.timeWaveforms?.time.length).toBe(5); + }); + + it('publishes warnings from the solver', async () => { + const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } }); + // FakeSolverAdapter does not currently emit warnings; vetting that the + // service forwards them is sufficient — see solver-port-contract for + // the warnings-field contract. + __setSchedulerSolverFactoryForTests(() => fake); + const sim = makeSimStore(simpleBoardWithBoard); + const elec = makeElectricalStore(); + const service = new CircuitSimulationService( + sim.port, + elec.port, + getMixedModeScheduler() as unknown as MixedModeSchedulerPort, + { collectBoardPinStates: () => ({}) }, + ); + service.start(); + await new Promise((r) => setTimeout(r, 10)); + // The FakeSolverAdapter returns empty warnings, so the snapshot + // also has empty warnings — but the field exists. + expect(elec.snapshots[0]?.warnings).toEqual([]); + }); + + it('logs but does not throw when solver fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fake = new FakeSolverAdapter(); + // Override solve to reject on first call. + let calls = 0; + fake.solve = async () => { + calls++; + throw new Error('boom'); + }; + __setSchedulerSolverFactoryForTests(() => fake); + const sim = makeSimStore(simpleBoardWithBoard); + const elec = makeElectricalStore(); + const service = new CircuitSimulationService( + sim.port, + elec.port, + getMixedModeScheduler() as unknown as MixedModeSchedulerPort, + { collectBoardPinStates: () => ({}) }, + ); + service.start(); + await new Promise((r) => setTimeout(r, 10)); + expect(warn).toHaveBeenCalled(); + expect(elec.snapshots.length).toBe(0); // no publish on failure + expect(calls).toBeGreaterThan(0); + warn.mockRestore(); + }); +}); diff --git a/frontend/src/simulation/spice/CircuitSimulationService.ts b/frontend/src/simulation/spice/CircuitSimulationService.ts new file mode 100644 index 00000000..60f222cc --- /dev/null +++ b/frontend/src/simulation/spice/CircuitSimulationService.ts @@ -0,0 +1,237 @@ +/** + * CircuitSimulationService — the orchestration layer that owns the + * simulation loop. + * + * Responsibilities (single, well-defined): + * 1. Listen to canvas state via an injected SimulatorStorePort. + * 2. Build the SPICE netlist via NetlistBuilder. + * 3. Drive the scheduler (loadCircuit + resolveDc / resolveTran). + * 4. Extract every voltage / branch current / waveform from the + * scheduler's last SolveResult and publish to the + * ElectricalStorePort so the 12 downstream consumers (ADC + * injection, instruments, overlays) keep working. + * 5. Coalesce concurrent solves: if one is in flight, mark a + * pending re-solve for after. + * + * Single source of truth: this service replaces the trio of + * - wireElectricalSolver (legacy) + * - connectLegacySolverToMixedMode (bridge) + * - connectMixedModeSchedulerToStore (Phase 1c step 1) + * + * Architecture: + * - Depends on PORTS only (SimulatorStorePort, ElectricalStorePort, + * MixedModeSchedulerPort). Easy to test with fakes. + * - No useSimulatorStore / useElectricalStore imports in this file + * — those bindings live in the wiring file (start.ts). + * - No SPICE-engine knowledge — that's in the adapters. + */ +import { buildInputFromStore } from './storeAdapter'; +import { buildNetlist } from './NetlistBuilder'; +import type { TimeWaveforms } from './types'; + +/** What the service needs from the simulator store. */ +export interface SimulatorStorePort { + getState(): { + components: Array<{ id: string; metadataId: string; properties: Record }>; + wires: Array<{ + id: string; + start: { componentId: string; pinName: string }; + end: { componentId: string; pinName: string }; + }>; + boards: Array<{ id: string; boardKind: string; pinStates?: Record }>; + }; + subscribe(listener: (state: unknown, prev: unknown) => void): () => void; +} + +/** What the service publishes to (the legacy electrical store, in our case). */ +export interface ElectricalStorePort { + /** Atomically write a complete solve snapshot. */ + publish(snapshot: ElectricalSnapshot): void; +} + +/** Domain-level solve result, decoupled from SolverPort details. */ +export interface ElectricalSnapshot { + /** SPICE net name → scalar voltage (V). For .tran: last sample. */ + nodeVoltages: Record; + /** V-source name (without leading V) → scalar branch current (A). */ + branchCurrents: Record; + /** "componentId:pinName" → SPICE net name (from NetlistBuilder). */ + pinNetMap: Map; + /** Which analysis produced this. */ + analysisMode: 'op' | 'tran' | 'ac'; + /** Per-sample waveforms — present only for .tran. */ + timeWaveforms?: TimeWaveforms; + /** Convergence warnings from the solver. */ + warnings: string[]; +} + +/** What the service needs from the scheduler. */ +export interface MixedModeSchedulerPort { + loadCircuit(netlist: string, pinNetMap: Map): Promise; + resolveDc(): Promise; + resolveTran(step: string, stop: string): Promise; + /** + * The last SolveResult the scheduler produced. Used by the service + * to extract waveforms / branch currents without going around the + * scheduler. + */ + getLastResult(): import('./ports/SolverPort').SolveResult | null; + /** + * Allow the service to request extra vectors of interest before + * the solve runs (branch currents, internal nets). Optional — + * implementations may ignore it if they don't optimise. + */ + setExtraVectorsOfInterest?(vectors: readonly string[]): void; +} + +export interface ServiceOptions { + /** Pre-existing pin states for board pins (from PinManager). */ + collectBoardPinStates: ( + boardId: string, + boardKind: string, + wires: SimulatorStorePort['getState'] extends () => infer S + ? S extends { wires: infer W } + ? W + : never + : never, + ) => Record; +} + +export class CircuitSimulationService { + private inFlight = false; + private pending = false; + + constructor( + private readonly simStore: SimulatorStorePort, + private readonly electricalStore: ElectricalStorePort, + private readonly scheduler: MixedModeSchedulerPort, + private readonly options: ServiceOptions, + ) {} + + /** Run one solve cycle, coalescing concurrent triggers. */ + async tick(): Promise { + if (this.inFlight) { + this.pending = true; + return; + } + this.inFlight = true; + try { + await this.runSolve(); + } catch (err) { + // Failures are reported via electrical-store warnings field; + // also logged so devtools / Sentry can see them. + // eslint-disable-next-line no-console + console.warn('[circuit-sim] solve failed:', err); + } finally { + this.inFlight = false; + if (this.pending) { + this.pending = false; + void this.tick(); + } + } + } + + private async runSolve(): Promise { + const state = this.simStore.getState(); + const snap = { + components: state.components, + wires: state.wires, + boards: state.boards.map((b) => ({ + id: b.id, + boardKind: b.boardKind, + pinStates: this.options.collectBoardPinStates( + b.id, + b.boardKind, + state.wires as never, + ) as never, + })), + }; + const input = buildInputFromStore(snap as Parameters[0]); + const { netlist, pinNetMap, nets, voltageSources } = buildNetlist(input); + + // Tell the scheduler exactly which vectors we want — every net + // voltage + every branch current. + const extraVectors: string[] = []; + for (const net of nets) extraVectors.push(`v(${net})`); + for (const vs of voltageSources) extraVectors.push(`i(${vs.toLowerCase()})`); + this.scheduler.setExtraVectorsOfInterest?.(extraVectors); + + await this.scheduler.loadCircuit(netlist, pinNetMap); + + if (input.analysis.kind === 'tran') { + await this.scheduler.resolveTran(input.analysis.step, input.analysis.stop); + } else { + await this.scheduler.resolveDc(); + } + + // Pull the SolveResult out of the scheduler and shape it for the + // electrical store. + const result = this.scheduler.getLastResult(); + if (!result) return; + + const nodeVoltages: Record = {}; + const branchCurrents: Record = {}; + let timeWaveforms: TimeWaveforms | undefined; + + for (const net of nets) { + const vec = result.vectors.get(`v(${net})`); + if (vec && vec.real.length > 0) { + nodeVoltages[net] = vec.real[vec.real.length - 1]!; + } + } + for (const vs of voltageSources) { + const key = `i(${vs.toLowerCase()})`; + const vec = result.vectors.get(key); + if (vec && vec.real.length > 0) { + // Store under the V-source name WITHOUT the leading "v_" — that's + // the convention legacy consumers (LED handler, Ammeter) use. + // Example: emission "V_led1_sense" → key "v_led1_sense". + const bcKey = vs.toLowerCase(); + branchCurrents[bcKey] = vec.real[vec.real.length - 1]!; + } + } + + if (input.analysis.kind === 'tran' && result.timeAxis.length > 0) { + const nodes = new Map(); + const branches = new Map(); + for (const net of nets) { + const vec = result.vectors.get(`v(${net})`); + if (vec && vec.real.length > 0) nodes.set(net, Array.from(vec.real)); + } + for (const vs of voltageSources) { + const vec = result.vectors.get(`i(${vs.toLowerCase()})`); + if (vec && vec.real.length > 0) branches.set(vs.toLowerCase(), Array.from(vec.real)); + } + timeWaveforms = { + time: Array.from(result.timeAxis), + nodes, + branches, + }; + } + + this.electricalStore.publish({ + nodeVoltages, + branchCurrents, + pinNetMap, + analysisMode: input.analysis.kind, + timeWaveforms, + warnings: result.warnings, + }); + } + + /** + * Mount the service: subscribe to store changes + run one initial + * solve. Returns an unsubscribe handle. + */ + start(): () => void { + const unsubscribe = this.simStore.subscribe((next, prev) => { + const n = next as ReturnType; + const p = prev as ReturnType; + if (n.components !== p.components || n.wires !== p.wires || n.boards !== p.boards) { + void this.tick(); + } + }); + void this.tick(); + return unsubscribe; + } +} diff --git a/frontend/src/simulation/spice/MixedModeScheduler.ts b/frontend/src/simulation/spice/MixedModeScheduler.ts index 47773444..86a64ad0 100644 --- a/frontend/src/simulation/spice/MixedModeScheduler.ts +++ b/frontend/src/simulation/spice/MixedModeScheduler.ts @@ -133,16 +133,28 @@ class MixedModeSchedulerImpl implements SpiceVoltageSource { return this.lastResult; } + private extraVectors: readonly string[] = []; + + /** + * Let an orchestrator (CircuitSimulationService) ask the solver + * for vectors beyond the pin-net set — branch currents, internal + * nets, etc. Replaces any previous set; pass [] to clear. + */ + setExtraVectorsOfInterest(vectors: readonly string[]): void { + this.extraVectors = vectors; + } + private async solveAndPublish(analysis: SolveAnalysis): Promise { const solver = this.solver; if (!solver) return; - // Build vectorsOfInterest from pinNetMap — every distinct non-ground - // net needs a v() read. + // Build vectorsOfInterest from pinNetMap (every non-ground net) + // plus whatever the orchestrator added. const vectorsOfInterest = new Set(); for (const net of this.pinNetMap.values()) { if (net !== '0') vectorsOfInterest.add(`v(${net})`); } + for (const v of this.extraVectors) vectorsOfInterest.add(v); const result = await solver.solve(analysis, { vectorsOfInterest: Array.from(vectorsOfInterest), diff --git a/frontend/src/simulation/spice/NetlistBuilder.ts b/frontend/src/simulation/spice/NetlistBuilder.ts index e81831c6..0ed5b0e9 100644 --- a/frontend/src/simulation/spice/NetlistBuilder.ts +++ b/frontend/src/simulation/spice/NetlistBuilder.ts @@ -34,6 +34,20 @@ export interface BuildNetlistResult { netlist: string; /** "boardId:pinName" → SPICE net name, from the same UF used to build the netlist. */ pinNetMap: Map; + /** + * Every SPICE net name in the circuit except canonical "0" (ground). + * Includes `vcc_rail` plus all auto-named nets (n0, n1, ...). Used + * by CircuitSimulationService to ask the solver for every node + * voltage in one shot (vectorsOfInterest). + */ + nets: string[]; + /** + * Every voltage source name in the circuit (without the leading + * `V` prefix is NOT how ngspice names them — they include the V). + * Examples: `V_VCC_RAIL`, `V_uno_9`, `V_led1_sense`. Used to + * request branch currents (`i(v_)`). + */ + voltageSources: string[]; } export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult { @@ -180,7 +194,25 @@ export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult { } } - return { netlist: lines.join('\n'), pinNetMap }; + // ── 10. Enumerate every net + voltage source for the solve options ─────── + // Distinct, non-ground nets — every node the solver should report. + const nets = Array.from(new Set(netNames.values())).filter((n) => n !== '0'); + // Voltage sources are any card starting with `V` (uppercase) followed + // by an underscore or digit. ngspice's case-insensitive match means + // both `Vname` and `vname` count. We emit only uppercase prefixes + // from componentToSpice + NetlistBuilder, so this regex is safe. + const voltageSources: string[] = []; + for (const card of cards) { + const m = card.match(/^([Vv][_\w]*)\s/); + if (m) voltageSources.push(m[1]); + } + + return { + netlist: lines.join('\n'), + pinNetMap, + nets, + voltageSources, + }; } // ── Helpers ────────────────────────────────────────────────────────────────