diff --git a/frontend/src/__tests__/mixed-mode-scheduler.test.ts b/frontend/src/__tests__/mixed-mode-scheduler.test.ts index 3917db4c..4c6a5632 100644 --- a/frontend/src/__tests__/mixed-mode-scheduler.test.ts +++ b/frontend/src/__tests__/mixed-mode-scheduler.test.ts @@ -1,75 +1,27 @@ /** - * Phase 1b continued — Step 1 tests for MixedModeScheduler. + * MixedModeScheduler tests — exercises the cache + fan-out + solver + * orchestration on top of a FakeSolverAdapter (no WASM). * - * Exercises the subscriber routing and voltage cache in isolation from - * the SPICE engine. The engine is never booted in these tests; we - * drive `publishVoltage` directly so the routing logic can be locked - * down before the real `alter + tran + readVec` loop lands. + * Layer covered: + * • voltage cache + subscriber routing + * • loadCircuit + resolveDc / resolveTran via the SolverPort + * • onMcuPinChange → alterSource → re-resolve loop * - * Coverage: - * - publishVoltage fires every matching subscriber and only those - * - getCurrentVoltage returns the last published value per pin - * - unsubscribe removes the callback cleanly - * - reset (via __resetMixedModeScheduler) clears state between tests + * Real ngspice integration is covered by the BJT-switch test; + * SolverPort contract is covered by solver-port-contract.test.ts. */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { getMixedModeScheduler, __resetMixedModeScheduler, - __setSchedulerEngineFactoryForTests, - type NgSpiceClient, + __setSchedulerSolverFactoryForTests, } from '../simulation/spice/MixedModeScheduler'; +import { FakeSolverAdapter } from '../simulation/spice/adapters/FakeSolverAdapter'; afterEach(() => { __resetMixedModeScheduler(); }); -/** Minimal in-memory NgSpiceClient — tracks calls and returns canned - * voltages for `readVec`. */ -function fakeClient(opts: { voltages?: Record } = {}): { - client: NgSpiceClient; - calls: { command: string[]; alter: Array<[string, number]>; loadedNetlist: string | null }; -} { - const voltages = opts.voltages ?? {}; - const calls = { - command: [] as string[], - alter: [] as Array<[string, number]>, - loadedNetlist: null as string | null, - }; - const client: NgSpiceClient = { - async init() {}, - async loadNetlist(netlist) { - calls.loadedNetlist = netlist; - }, - async command(cmd) { - calls.command.push(cmd); - return { rc: 0, stdout: [], stderr: [] }; - }, - async alter(name, value) { - calls.alter.push([name, value]); - return undefined; - }, - async readVec(name) { - // Strip 'v(' / ')' to look up by net name. - const match = name.match(/^v\((.+)\)$/i); - const netName = match ? match[1] : name; - const v = voltages[netName]; - if (v === undefined) { - throw new Error(`unknown vec ${name}`); - } - return { - name, - real: new Float64Array([v]), - imag: null, - complex: false, - unit: 'V', - }; - }, - dispose() {}, - }; - return { client, calls }; -} - describe('MixedModeScheduler — voltage cache', () => { it('returns null until something is published', () => { const sched = getMixedModeScheduler(); @@ -84,7 +36,7 @@ describe('MixedModeScheduler — voltage cache', () => { expect(sched.getCurrentVoltage('q1', 'C')).toBe(4.5); expect(sched.getCurrentVoltage('q1', 'B')).toBe(1.2); expect(sched.getCurrentVoltage('q2', 'C')).toBe(0.3); - sched.publishVoltage('q1', 'C', 2.7); // overwrite + sched.publishVoltage('q1', 'C', 2.7); expect(sched.getCurrentVoltage('q1', 'C')).toBe(2.7); }); }); @@ -132,42 +84,27 @@ describe('MixedModeScheduler — subscribe / publish routing', () => { expect(cb).toHaveBeenCalledTimes(1); cancel(); sched.publishVoltage('q1', 'C', 0.3); - expect(cb).toHaveBeenCalledTimes(1); // not called again - }); - - it('reset clears subscribers and voltage cache', () => { - const sched = getMixedModeScheduler(); - const cb = vi.fn(); - sched.subscribe('q1', 'C', cb); - sched.publishVoltage('q1', 'C', 4.7); - __resetMixedModeScheduler(); - - const sched2 = getMixedModeScheduler(); - expect(sched2).not.toBe(sched); - expect(sched2.getCurrentVoltage('q1', 'C')).toBeNull(); - sched2.publishVoltage('q1', 'C', 0.5); - // The old subscriber attached to the disposed scheduler must NOT - // fire from the new scheduler instance. expect(cb).toHaveBeenCalledTimes(1); }); }); -describe('MixedModeScheduler — loadCircuit + resolveDc (Step 2)', () => { - it('loadCircuit calls engine.loadNetlist exactly once with the supplied netlist', async () => { - const { client, calls } = fakeClient(); - __setSchedulerEngineFactoryForTests(() => client); +describe('MixedModeScheduler — loadCircuit + resolveDc', () => { + it('loadCircuit passes the netlist to the solver', async () => { + const fake = new FakeSolverAdapter(); + __setSchedulerSolverFactoryForTests(() => fake); const sched = getMixedModeScheduler(); - const netlist = 'V1 1 0 DC 5\n.op\n.end\n'; + const netlist = 'V1 1 0 DC 5\n.end\n'; await sched.loadCircuit(netlist, new Map([['comp:p', '1']])); - expect(calls.loadedNetlist).toBe(netlist); + expect(fake.calls.loadCircuit).toEqual([netlist]); + expect(fake.calls.init).toBe(1); }); - it('resolveDc fires .op and publishes voltages for every pin in pinNetMap', async () => { - const { client, calls } = fakeClient({ - voltages: { net_drain: 4.97, net_gate: 0.5 }, + it('resolveDc requests the right vectors and publishes per pinNetMap', async () => { + const fake = new FakeSolverAdapter({ + vectors: { 'v(net_drain)': 4.97, 'v(net_gate)': 0.5 }, }); - __setSchedulerEngineFactoryForTests(() => client); + __setSchedulerSolverFactoryForTests(() => fake); const sched = getMixedModeScheduler(); await sched.loadCircuit( @@ -179,31 +116,34 @@ describe('MixedModeScheduler — loadCircuit + resolveDc (Step 2)', () => { ]), ); - const events: Array<{ id: string; pin: string; v: number }> = []; - sched.subscribe('q1', 'D', (_state, v) => events.push({ id: 'q1', pin: 'D', v })); - sched.subscribe('q1', 'G', (_state, v) => events.push({ id: 'q1', pin: 'G', v })); - sched.subscribe('q1', 'S', (_state, v) => events.push({ id: 'q1', pin: 'S', v })); + const events: Array<{ pin: string; v: number }> = []; + sched.subscribe('q1', 'D', (_state, v) => events.push({ pin: 'D', v })); + sched.subscribe('q1', 'G', (_state, v) => events.push({ pin: 'G', v })); + sched.subscribe('q1', 'S', (_state, v) => events.push({ pin: 'S', v })); await sched.resolveDc(); - expect(calls.command).toContain('op'); + expect(fake.calls.solve).toHaveLength(1); + expect(fake.calls.solve[0]?.analysis).toEqual({ kind: 'op' }); + expect(new Set(fake.calls.solve[0]?.vectorsOfInterest)).toEqual( + new Set(['v(net_drain)', 'v(net_gate)']), + ); + // Ground pin doesn't go through the solver — short-circuited to 0V. expect(sched.getCurrentVoltage('q1', 'D')).toBeCloseTo(4.97); expect(sched.getCurrentVoltage('q1', 'G')).toBeCloseTo(0.5); - // Ground pins resolve to 0 without a readVec call (net '0' shortcut). expect(sched.getCurrentVoltage('q1', 'S')).toBe(0); - // All three subscribers received their published voltage. expect(events).toEqual( expect.arrayContaining([ - { id: 'q1', pin: 'D', v: expect.closeTo(4.97, 2) }, - { id: 'q1', pin: 'G', v: expect.closeTo(0.5, 2) }, - { id: 'q1', pin: 'S', v: 0 }, + { pin: 'D', v: expect.closeTo(4.97, 2) }, + { pin: 'G', v: expect.closeTo(0.5, 2) }, + { pin: 'S', v: 0 }, ]), ); }); - it('resolveDc tolerates pins whose net is not in the analysis', async () => { - const { client } = fakeClient({ voltages: { net_present: 3.3 } }); - __setSchedulerEngineFactoryForTests(() => client); + it('resolveDc tolerates pins whose net is not in the solver result', async () => { + const fake = new FakeSolverAdapter({ vectors: { 'v(net_present)': 3.3 } }); + __setSchedulerSolverFactoryForTests(() => fake); const sched = getMixedModeScheduler(); await sched.loadCircuit( @@ -213,7 +153,6 @@ describe('MixedModeScheduler — loadCircuit + resolveDc (Step 2)', () => { ['comp:M', 'net_missing'], ]), ); - // Must not throw even though net_missing has no canned voltage. await sched.resolveDc(); expect(sched.getCurrentVoltage('comp', 'P')).toBeCloseTo(3.3); expect(sched.getCurrentVoltage('comp', 'M')).toBeNull(); @@ -224,35 +163,58 @@ describe('MixedModeScheduler — loadCircuit + resolveDc (Step 2)', () => { await expect(sched.resolveDc()).rejects.toThrow(/loadCircuit first/i); }); - it('onMcuPinChange alters the matching V source and republishes voltages', async () => { + it('resolveTran issues .tran and publishes the steady-state sample per pin', async () => { + const fake = new FakeSolverAdapter({ + vectors: { 'v(out)': new Float64Array([0, 1, 2, 3, 4.5]) }, + timeAxis: new Float64Array([0, 1e-4, 2e-4, 3e-4, 4e-4]), + }); + __setSchedulerSolverFactoryForTests(() => fake); + const sched = getMixedModeScheduler(); + await sched.loadCircuit('* netlist', new Map([['comp:OUT', 'out']])); + await sched.resolveTran('1e-4', '4e-4'); + + expect(fake.calls.solve[0]?.analysis).toEqual({ + kind: 'tran', + step: '1e-4', + stop: '4e-4', + }); + // Steady-state = last sample = 4.5 + expect(sched.getCurrentVoltage('comp', 'OUT')).toBeCloseTo(4.5); + // Full waveform reachable via getLastResult for downstream consumers. + expect(sched.getLastResult()?.vectors.get('v(out)')?.real.length).toBe(5); + expect(sched.getLastResult()?.timeAxis.length).toBe(5); + }); + + it('loadCircuit replaces the previous circuit and clears the voltage cache', async () => { + const fake = new FakeSolverAdapter({ vectors: { 'v(net_a)': 1.1, 'v(net_b)': 2.2 } }); + __setSchedulerSolverFactoryForTests(() => fake); + const sched = getMixedModeScheduler(); + + await sched.loadCircuit('first', new Map([['x:p', 'net_a']])); + await sched.resolveDc(); + expect(sched.getCurrentVoltage('x', 'p')).toBeCloseTo(1.1); + + await sched.loadCircuit('second', new Map([['y:q', 'net_b']])); + expect(sched.getCurrentVoltage('x', 'p')).toBeNull(); + await sched.resolveDc(); + expect(sched.getCurrentVoltage('y', 'q')).toBeCloseTo(2.2); + }); +}); + +describe('MixedModeScheduler — onMcuPinChange', () => { + it('alters the matching V source and republishes voltages', async () => { let drainV = 4.9; let gateV = 0; - const client: NgSpiceClient = { - async init() {}, - async loadNetlist() {}, - async command(_cmd) { - return { rc: 0, stdout: [], stderr: [] }; - }, - async alter(name, value) { - // Simulate the analog response: the gate net follows the - // arduino source, and the drain swings between high and low as - // the gate crosses Vth. - if (name === 'V_uno_9') { - gateV = value; - drainV = value >= 1.6 ? 0.05 : 4.9; - } - return undefined; - }, - async readVec(name) { - const m = name.match(/^v\((.+)\)$/i); - const net = m ? m[1] : name; - if (net === 'net_drain') return { name, real: new Float64Array([drainV]), imag: null, complex: false, unit: 'V' }; - if (net === 'net_gate') return { name, real: new Float64Array([gateV]), imag: null, complex: false, unit: 'V' }; - throw new Error('unknown net'); - }, - dispose() {}, + const fake = new FakeSolverAdapter({ + vectors: () => ({ 'v(net_drain)': drainV, 'v(net_gate)': gateV }), + }); + fake.onAlter = (name, value) => { + if (name === 'V_uno_9') { + gateV = value; + drainV = value >= 1.6 ? 0.05 : 4.9; + } }; - __setSchedulerEngineFactoryForTests(() => client); + __setSchedulerSolverFactoryForTests(() => fake); const sched = getMixedModeScheduler(); await sched.loadCircuit( '* netlist', @@ -265,38 +227,18 @@ describe('MixedModeScheduler — loadCircuit + resolveDc (Step 2)', () => { expect(sched.getCurrentVoltage('q1', 'D')).toBeCloseTo(4.9); expect(sched.getCurrentVoltage('q1', 'G')).toBeCloseTo(0); - // MCU drives pin 9 HIGH at 5V → gate follows, drain pulls down. await sched.onMcuPinChange('uno', '9', true, 5); + expect(fake.calls.alterSource).toEqual([['V_uno_9', 5]]); expect(sched.getCurrentVoltage('q1', 'G')).toBeCloseTo(5); expect(sched.getCurrentVoltage('q1', 'D')).toBeCloseTo(0.05); - // MCU drives pin 9 LOW → drain restores. await sched.onMcuPinChange('uno', '9', false, 5); expect(sched.getCurrentVoltage('q1', 'G')).toBeCloseTo(0); expect(sched.getCurrentVoltage('q1', 'D')).toBeCloseTo(4.9); }); - it('onMcuPinChange is a no-op when no engine has been started', async () => { + it('is a no-op when no solver has been started', async () => { const sched = getMixedModeScheduler(); - // No __setSchedulerEngineFactoryForTests; no loadCircuit. Must not throw. - await expect( - sched.onMcuPinChange('uno', '9', true, 5), - ).resolves.toBeUndefined(); - }); - - it('loadCircuit replaces the previous circuit and clears the voltage cache', async () => { - const { client } = fakeClient({ voltages: { net_a: 1.1, net_b: 2.2 } }); - __setSchedulerEngineFactoryForTests(() => client); - const sched = getMixedModeScheduler(); - - await sched.loadCircuit('first', new Map([['x:p', 'net_a']])); - await sched.resolveDc(); - expect(sched.getCurrentVoltage('x', 'p')).toBeCloseTo(1.1); - - await sched.loadCircuit('second', new Map([['y:q', 'net_b']])); - // Cache for the old pin is gone immediately on reload. - expect(sched.getCurrentVoltage('x', 'p')).toBeNull(); - await sched.resolveDc(); - expect(sched.getCurrentVoltage('y', 'q')).toBeCloseTo(2.2); + await expect(sched.onMcuPinChange('uno', '9', true, 5)).resolves.toBeUndefined(); }); }); diff --git a/frontend/src/simulation/spice/MixedModeScheduler.ts b/frontend/src/simulation/spice/MixedModeScheduler.ts index 69554c40..47773444 100644 --- a/frontend/src/simulation/spice/MixedModeScheduler.ts +++ b/frontend/src/simulation/spice/MixedModeScheduler.ts @@ -1,77 +1,47 @@ /** - * MixedModeScheduler — orchestrates the digital ↔ SPICE coupling for - * Phase 1b of the mixed-mode simulator project. + * MixedModeScheduler — voltage event bus + solver orchestrator. * - * Architecture in three layers: + * Architecture (Phase 1c onwards): * - * ┌────────────────────────────┐ - * │ MCU sim (AVR / RP2040 / │ fires PinManager.onPinChange() - * │ ESP32 bridge) │ events on every digitalWrite() - * └─────────────┬──────────────┘ - * │ pin edge - * ▼ - * ┌────────────────────────────┐ - * │ MixedModeScheduler │ batches edges, builds netlist via - * │ • alter V_pin sources │ NetlistBuilder, drives ngspice via - * │ • short tran advance │ NgSpiceInteractive - * │ • read v(node) for each │ - * │ component pin │ - * └─────────────┬──────────────┘ - * │ node voltage event - * ▼ - * ┌────────────────────────────┐ - * │ SpiceResolvedPinResolver │ threshold-converts v → HIGH/LOW, - * │ │ fires component handler callback - * └────────────────────────────┘ + * ┌─────────────────────────────┐ + * │ CircuitSimulationService │ builds netlist, calls scheduler + * │ (or any caller of the │ loadCircuit + resolveDc / onMcu + * │ public methods below) │ + * └────────────┬────────────────┘ + * │ + * ▼ + * ┌─────────────────────────────┐ + * │ MixedModeScheduler │ + * │ • injects a SolverPort │ ── solver.loadCircuit / solve / alter + * │ • caches voltages │ + * │ • fans out to subscribers │ ── SpiceResolvedPinResolver.onChange + * └────────────┬────────────────┘ + * │ SolverPort + * ▼ + * ┌─────────────────────────────┐ + * │ NgSpiceWorkerAdapter (prod) │ + * │ NgSpiceNodeAdapter (tests) │ + * │ FakeSolverAdapter (unit) │ + * └─────────────────────────────┘ * - * Phase 1a vendored NgSpiceInteractive and the WASM build. This file is - * the Phase 1b skeleton — the API and lifecycle are in place, but the - * actual `alter + tran + readVec` loop is marked TODO because - * (a) the WASM is single-threaded, so `bg_run` is not useful and we - * need the short-tran workaround, and - * (b) the netlist build flow needs to be re-wired from the existing - * 200 ms polling in `subscribeToStore.ts` to event-driven. + * The scheduler does NOT know about ngspice, WASM, or Web Workers — + * those are adapter concerns. Domain code (PinResolver, components) + * sees only the SpiceVoltageSource interface (`subscribe`, + * `getCurrentVoltage`). * - * For now, the scheduler exposes the API surface that component - * handlers and DynamicComponent will use, plus a `start()` / - * `stop()` lifecycle controlled by `useSimulatorStore.boards[*].running`. - * When `start()` is called the scheduler logs "started" and components - * subscribing to it get FLOATING resolutions — i.e. behavior - * indistinguishable from "SPICE not available". Phase 1b's next - * sub-task replaces the stub data flow with real readVec calls. - * - * See: - * project/sim-mixedmode/phase-01-mixed-mode-coupling.md - * simulation/spice/wasm/NgSpiceInteractive.ts + * The cache + fan-out semantics live here because they're tied to + * the (componentId, componentPinName) pair, which is a domain + * concept. The solver speaks SPICE-net names; the scheduler maps + * between the two via the pinNetMap. */ -import { NgSpiceInteractive } from './wasm/NgSpiceInteractive'; +import type { SolverPort, SolveAnalysis } from './ports/SolverPort'; +import { NgSpiceWorkerAdapter } from './adapters/NgSpiceWorkerAdapter'; import type { PinState, SpiceVoltageSource } from '../PinResolver'; -/** - * The subset of NgSpiceInteractive the scheduler depends on. Spelled - * out as an interface so unit tests can inject a mock without booting - * the WASM worker. - */ -export interface NgSpiceClient { - init(): Promise; - loadNetlist(netlist: string): Promise; - command(cmd: string): Promise<{ rc: number; stdout: string[]; stderr: string[] }>; - alter(sourceName: string, dcValue: number): Promise; - readVec(name: string): Promise<{ - name: string; - real: Float64Array; - imag: Float64Array | null; - complex: boolean; - unit: string; - }>; - dispose(): void; -} - /** * Identity of a "pin of interest" — a place a SpiceResolvedPinResolver - * is watching for voltage changes. The (boardId, pinName) → SPICE-net - * mapping is built lazily as components register. + * is watching for voltage changes. */ export interface NodeSubscription { componentId: string; @@ -81,22 +51,14 @@ export interface NodeSubscription { type SubscriptionToken = number; -/** - * Singleton-style scheduler. Multiple components use the same SPICE - * engine instance; there's no value in running parallel solvers. - * - * Phase 1b: the scheduler holds the engine + the subscription registry - * but does NOT yet drive real SPICE solves on pin edges. Phase 1b - * continued: implement the alter+tran+readVec loop, hook NetlistBuilder. - */ /** Voltage cache key = `${componentId}|${componentPinName}`. */ function pinKey(componentId: string, componentPinName: string): string { return `${componentId}|${componentPinName}`; } class MixedModeSchedulerImpl implements SpiceVoltageSource { - private engine: NgSpiceClient | null = null; - private engineFactory: () => NgSpiceClient = () => new NgSpiceInteractive(); + private solver: SolverPort | null = null; + private solverFactory: () => SolverPort = () => new NgSpiceWorkerAdapter(); private nextToken: SubscriptionToken = 1; private subscriptions = new Map(); private voltages = new Map(); @@ -105,63 +67,90 @@ class MixedModeSchedulerImpl implements SpiceVoltageSource { private running = false; private initPromise: Promise | null = null; - /** True while the scheduler is actively driving the SPICE engine. */ + /** True while the scheduler is actively driving the solver. */ isRunning(): boolean { return this.running; } - /** - * Start the scheduler. Lazy-loads the WASM engine on first call. No-op - * if already running. Called from `useSimulatorStore` when any board - * transitions to running. - */ + /** Lazy-boot the solver (idempotent). */ async start(): Promise { if (this.running) return; - if (!this.engine) { - this.engine = this.engineFactory(); - } - if (!this.initPromise) { - this.initPromise = this.engine.init(); - } - await this.initPromise; + await this.ensureSolver(); this.running = true; } + private async ensureSolver(): Promise { + if (!this.solver) this.solver = this.solverFactory(); + if (!this.initPromise) this.initPromise = this.solver.init(); + await this.initPromise; + return this.solver; + } + /** - * Load a SPICE netlist plus the (component, pin) → SPICE-net mapping - * produced by `NetlistBuilder.buildNetlist`. Replaces any previously - * loaded circuit. Subsequent `resolveDc` / `alter` / `onMcuPinChange` - * calls operate on this circuit. - * - * Idempotent in the sense that calling it again with a fresh circuit - * simply re-loads — the engine is kept warm. Pin-net mapping keys - * use the NetlistBuilder convention `${componentId}:${pinName}`. + * Load a SPICE netlist plus the (component, pin) → SPICE-net map + * produced by `NetlistBuilder.buildNetlist`. Replaces any + * previously loaded circuit; clears the voltage cache. */ async loadCircuit(netlist: string, pinNetMap: Map): Promise { - if (!this.engine) { - this.engine = this.engineFactory(); - } - if (!this.initPromise) { - this.initPromise = this.engine.init(); - } - await this.initPromise; - await this.engine.loadNetlist(netlist); + const solver = await this.ensureSolver(); + await solver.loadCircuit(netlist); this.pinNetMap = new Map(pinNetMap); - // Voltages cache is now stale — clear it. resolveDc() will repopulate. this.voltages.clear(); } /** - * Run a DC operating-point solve and publish the resolved voltage for - * every (component, pin) currently in the pinNetMap. Subscribers - * fire as voltages land in the cache. Ground pins (canonical net - * `0`) are published as 0 V without a readVec round-trip. + * Run a `.op` solve and publish voltages for every (component, pin) + * currently in pinNetMap. Ground pins (net = `0`) publish 0 V + * without a vector read. */ async resolveDc(): Promise { - if (!this.engine) { + if (!this.solver) { throw new Error('MixedModeScheduler.resolveDc(): call loadCircuit first'); } - await this.engine.command('op'); + await this.solveAndPublish({ kind: 'op' }); + } + + /** + * Run a `.tran` solve and publish the steady-state (last-sample) + * voltage for every (component, pin) in pinNetMap. The full + * waveform is available via `getLastResult()` for callers that need + * the time series. + */ + async resolveTran(step: string, stop: string): Promise { + if (!this.solver) { + throw new Error('MixedModeScheduler.resolveTran(): call loadCircuit first'); + } + await this.solveAndPublish({ kind: 'tran', step, stop }); + } + + private lastResult: import('./ports/SolverPort').SolveResult | null = null; + + /** + * Last full solve result, for callers that need the raw vectors + * (e.g. CircuitSimulationService when populating useElectricalStore). + */ + getLastResult(): import('./ports/SolverPort').SolveResult | null { + return this.lastResult; + } + + 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. + const vectorsOfInterest = new Set(); + for (const net of this.pinNetMap.values()) { + if (net !== '0') vectorsOfInterest.add(`v(${net})`); + } + + const result = await solver.solve(analysis, { + vectorsOfInterest: Array.from(vectorsOfInterest), + }); + this.lastResult = result; + + // Publish the last sample per (component, pin). For .op that's + // the single point; for .tran it's the steady-state. for (const [key, net] of this.pinNetMap) { const idx = key.indexOf(':'); if (idx < 0) continue; @@ -171,55 +160,37 @@ class MixedModeSchedulerImpl implements SpiceVoltageSource { this.publishVoltage(componentId, pinName, 0); continue; } - try { - const vec = await this.engine.readVec(`v(${net})`); - const v = vec.real[0] ?? 0; - this.publishVoltage(componentId, pinName, v); - } catch { - // Net wasn't part of this analysis — skip silently so a single - // disconnected component pin doesn't break the whole resolve. - } + const vec = result.vectors.get(`v(${net})`); + if (!vec) continue; // disconnected pin — leave unpublished + const v = vec.real[vec.real.length - 1] ?? 0; + this.publishVoltage(componentId, pinName, v); } } - /** - * Stop the scheduler. Components stay subscribed but stop receiving - * SPICE-resolved events until the next start(). - */ + /** Stop the scheduler. Engine stays warm so restart is cheap. */ stop(): void { if (!this.running) return; this.running = false; - // TODO Phase 1b — pause the SPICE driver loop. Engine instance is - // intentionally kept warm so restart is cheap; dispose only on - // unmount or shutdown. } - /** - * Tear down the engine entirely. Used on app unmount; in normal flow - * we just stop() + start() to avoid re-paying the ~2-5 s WASM init - * cost. - */ + /** Tear down the solver entirely. */ dispose(): void { this.running = false; - if (this.engine) { - this.engine.dispose(); - this.engine = null; + if (this.solver) { + this.solver.dispose(); + this.solver = null; } this.initPromise = null; this.subscriptions.clear(); this.voltages.clear(); this.pinNetMap.clear(); + this.lastResult = null; } /** - * Register a component pin to receive SPICE-resolved voltage events. - * Implements the SpiceVoltageSource contract used by - * `createSpiceResolvedPinResolver`. Returns an unsubscribe handle. - * - * Phase 1b: stub — no events ever fire. The caller's resolver will - * report whatever its fallback state is (typically FLOATING) and - * never transition. Phase 1b continued: actually emit events when - * SPICE solves complete. + * Register a component pin to receive voltage events. Implements + * `SpiceVoltageSource` so `createSpiceResolvedPinResolver` can use + * the scheduler directly. */ subscribe( componentId: string, @@ -233,56 +204,31 @@ class MixedModeSchedulerImpl implements SpiceVoltageSource { }; } - /** - * Look up the latest known voltage on a component pin's SPICE net. - * Returns the value last published via `publishVoltage`, or null if - * nothing has been published for that pin yet. Phase 1b continued - * will populate this cache from `NgSpiceInteractive.readVec` after - * each solve. - */ + /** Latest cached voltage for a (component, pin), or null. */ getCurrentVoltage(componentId: string, componentPinName: string): number | null { const v = this.voltages.get(pinKey(componentId, componentPinName)); return v === undefined ? null : v; } /** - * Publish a freshly-resolved voltage for a (component, pin) and - * notify all subscribers watching that key. Stores the value in - * the cache so subsequent `getCurrentVoltage` calls see it. - * - * The SPICE-resolved PinResolver does its own threshold conversion, - * so this layer only forwards raw volts with a placeholder - * `'UNKNOWN'` state — the resolver re-derives HIGH/LOW from the - * voltage using its configured thresholds. Skipping the threshold - * decision here keeps the scheduler I/O-family-agnostic. + * Publish a freshly-resolved voltage and notify subscribers. + * SpiceResolvedPinResolver does its own threshold conversion, so + * this layer forwards the raw volts with an `'UNKNOWN'` sentinel + * state — the resolver re-derives HIGH/LOW. */ publishVoltage(componentId: string, componentPinName: string, voltage: number): void { this.voltages.set(pinKey(componentId, componentPinName), voltage); for (const sub of this.subscriptions.values()) { if (sub.componentId === componentId && sub.componentPinName === componentPinName) { - // 'UNKNOWN' is a sentinel — the SpiceResolvedPinResolver re- - // computes the state from the voltage via its threshold - // configuration. We could pass any string here; 'UNKNOWN' is - // the convention used in the Phase 1b unit tests. sub.cb('UNKNOWN' as PinState, voltage); } } } /** - * Notify the scheduler that an MCU pin changed state. Issues an - * `alter V__ dc ` to ngspice, re-runs the DC - * operating point, and refreshes the voltage cache + subscribers for - * every (component, pin) in the current pinNetMap. - * - * Caller is responsible for converting the digital state to a - * voltage: typically `state ? vcc : 0`, but a board with output - * impedance or open-drain semantics may use a different mapping. - * - * Returns a promise that resolves after the resulting `resolveDc` - * completes. When `start()` hasn't been called yet (no engine), the - * call is a silent no-op so legacy code paths that fire this - * unconditionally don't crash. + * MCU pin transition → alter the corresponding V source + re-resolve. + * Silent no-op when no solver has been started (lets legacy callers + * fire without crashing). */ async onMcuPinChange( boardId: string, @@ -290,15 +236,15 @@ class MixedModeSchedulerImpl implements SpiceVoltageSource { state: boolean, vcc: number, ): Promise { - if (!this.engine) return; + if (!this.solver) return; const sourceName = `V_${boardId}_${pinName}`; const voltage = state ? vcc : 0; - await this.engine.alter(sourceName, voltage); + await this.solver.alterSource(sourceName, voltage); await this.resolveDc(); } } -/** The one and only scheduler. Lazily constructed. */ +/** Singleton accessor. */ let instance: MixedModeSchedulerImpl | null = null; export function getMixedModeScheduler(): MixedModeSchedulerImpl { @@ -306,24 +252,21 @@ export function getMixedModeScheduler(): MixedModeSchedulerImpl { return instance; } -/** Test helper — drops the singleton so test runs don't pollute each - * other. NEVER call from production code. */ +/** Test helper — drop the singleton so each test starts clean. */ export function __resetMixedModeScheduler(): void { if (instance) instance.dispose(); instance = null; } -/** Test helper — inject a fake NgSpiceClient so `loadCircuit` / - * `resolveDc` can be exercised without a real WASM worker. The factory - * is invoked the next time the scheduler instantiates its engine. - * Must be called BEFORE `start()` / `loadCircuit()`. */ -export function __setSchedulerEngineFactoryForTests( - factory: () => NgSpiceClient, -): void { +/** + * Test helper — inject a custom SolverPort factory. Must be called + * before any `start()` / `loadCircuit()` on the singleton. + */ +export function __setSchedulerSolverFactoryForTests(factory: () => SolverPort): void { const sched = getMixedModeScheduler() as unknown as { - engineFactory: () => NgSpiceClient; + solverFactory: () => SolverPort; }; - sched.engineFactory = factory; + sched.solverFactory = factory; } export type MixedModeScheduler = MixedModeSchedulerImpl;