diff --git a/frontend/src/__tests__/mixed-mode-scheduler.test.ts b/frontend/src/__tests__/mixed-mode-scheduler.test.ts index 8e206df7..3917db4c 100644 --- a/frontend/src/__tests__/mixed-mode-scheduler.test.ts +++ b/frontend/src/__tests__/mixed-mode-scheduler.test.ts @@ -16,12 +16,60 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { getMixedModeScheduler, __resetMixedModeScheduler, + __setSchedulerEngineFactoryForTests, + type NgSpiceClient, } from '../simulation/spice/MixedModeScheduler'; 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(); @@ -103,3 +151,152 @@ describe('MixedModeScheduler — subscribe / publish routing', () => { 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); + const sched = getMixedModeScheduler(); + + const netlist = 'V1 1 0 DC 5\n.op\n.end\n'; + await sched.loadCircuit(netlist, new Map([['comp:p', '1']])); + expect(calls.loadedNetlist).toBe(netlist); + }); + + 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 }, + }); + __setSchedulerEngineFactoryForTests(() => client); + const sched = getMixedModeScheduler(); + + await sched.loadCircuit( + '* netlist', + new Map([ + ['q1:D', 'net_drain'], + ['q1:G', 'net_gate'], + ['q1:S', '0'], + ]), + ); + + 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 })); + + await sched.resolveDc(); + + expect(calls.command).toContain('op'); + 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 }, + ]), + ); + }); + + it('resolveDc tolerates pins whose net is not in the analysis', async () => { + const { client } = fakeClient({ voltages: { net_present: 3.3 } }); + __setSchedulerEngineFactoryForTests(() => client); + const sched = getMixedModeScheduler(); + + await sched.loadCircuit( + '* netlist', + new Map([ + ['comp:P', 'net_present'], + ['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(); + }); + + it('resolveDc without loadCircuit first throws a clear error', async () => { + const sched = getMixedModeScheduler(); + await expect(sched.resolveDc()).rejects.toThrow(/loadCircuit first/i); + }); + + it('onMcuPinChange 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() {}, + }; + __setSchedulerEngineFactoryForTests(() => client); + const sched = getMixedModeScheduler(); + await sched.loadCircuit( + '* netlist', + new Map([ + ['q1:D', 'net_drain'], + ['q1:G', 'net_gate'], + ]), + ); + await sched.resolveDc(); + 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(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 () => { + 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); + }); +}); diff --git a/frontend/src/simulation/spice/MixedModeScheduler.ts b/frontend/src/simulation/spice/MixedModeScheduler.ts index 623d42ab..69554c40 100644 --- a/frontend/src/simulation/spice/MixedModeScheduler.ts +++ b/frontend/src/simulation/spice/MixedModeScheduler.ts @@ -48,6 +48,26 @@ import { NgSpiceInteractive } from './wasm/NgSpiceInteractive'; 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 @@ -75,10 +95,13 @@ function pinKey(componentId: string, componentPinName: string): string { } class MixedModeSchedulerImpl implements SpiceVoltageSource { - private engine: NgSpiceInteractive | null = null; + private engine: NgSpiceClient | null = null; + private engineFactory: () => NgSpiceClient = () => new NgSpiceInteractive(); private nextToken: SubscriptionToken = 1; private subscriptions = new Map(); private voltages = new Map(); + /** `${componentId}:${pinName}` → SPICE net name (from NetlistBuilder). */ + private pinNetMap = new Map(); private running = false; private initPromise: Promise | null = null; @@ -95,16 +118,68 @@ class MixedModeSchedulerImpl implements SpiceVoltageSource { async start(): Promise { if (this.running) return; if (!this.engine) { - this.engine = new NgSpiceInteractive(); + this.engine = this.engineFactory(); } if (!this.initPromise) { this.initPromise = this.engine.init(); } await this.initPromise; this.running = true; - // TODO Phase 1b — wire up the alter+tran+readVec loop here. - // Build initial netlist via NetlistBuilder, send to engine via - // loadNetlist, then enter the event-driven update cycle. + } + + /** + * 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}`. + */ + 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); + 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. + */ + async resolveDc(): Promise { + if (!this.engine) { + throw new Error('MixedModeScheduler.resolveDc(): call loadCircuit first'); + } + await this.engine.command('op'); + for (const [key, net] of this.pinNetMap) { + const idx = key.indexOf(':'); + if (idx < 0) continue; + const componentId = key.slice(0, idx); + const pinName = key.slice(idx + 1); + if (net === '0') { + 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. + } + } } /** @@ -132,6 +207,8 @@ class MixedModeSchedulerImpl implements SpiceVoltageSource { } this.initPromise = null; this.subscriptions.clear(); + this.voltages.clear(); + this.pinNetMap.clear(); } /** @@ -193,16 +270,31 @@ class MixedModeSchedulerImpl implements SpiceVoltageSource { } /** - * Notify the scheduler that an MCU pin changed state. Phase 1b will - * translate this into `alter V__ dc ` + a short - * `tran` step, then read affected nodes and dispatch to subscribers. + * 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. * - * Phase 1b skeleton: no-op. Component handlers continue to receive - * events from the legacy PinManager path until Phase 1b continued. + * 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. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onMcuPinChange(_boardId: string, _pinName: string, _state: boolean, _vcc: number): void { - // intentionally empty + async onMcuPinChange( + boardId: string, + pinName: string, + state: boolean, + vcc: number, + ): Promise { + if (!this.engine) return; + const sourceName = `V_${boardId}_${pinName}`; + const voltage = state ? vcc : 0; + await this.engine.alter(sourceName, voltage); + await this.resolveDc(); } } @@ -221,4 +313,17 @@ export function __resetMixedModeScheduler(): void { 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 { + const sched = getMixedModeScheduler() as unknown as { + engineFactory: () => NgSpiceClient; + }; + sched.engineFactory = factory; +} + export type MixedModeScheduler = MixedModeSchedulerImpl;