feat(sim): Phase 1b continued, steps 2 + 3 — loadCircuit, resolveDc, onMcuPinChange

Wires the second half of the mixed-mode event loop on top of the
voltage cache that step 1 added.

Step 2 — loadCircuit + resolveDc:
- `loadCircuit(netlist, pinNetMap)` accepts the artifacts that
  NetlistBuilder already produces, boots the engine lazily, calls
  `loadNetlist`, and clears the voltage cache so stale values from a
  previous circuit cannot leak through.
- `resolveDc()` runs `op` and walks the pinNetMap, calling readVec for
  each non-ground net and publishVoltage for each pin. Ground pins
  short-circuit to 0 V without an extra round-trip. Missing nets are
  skipped quietly so a disconnected probe pin can't break the resolve.

Step 3 — onMcuPinChange:
- Issues `alter V_<board>_<pin> dc <volts>` and re-resolves. Caller
  decides the volts: `state ? vcc : 0` for plain digital, but boards
  with open-drain / output-impedance semantics can pass any number.
- Silent no-op when no engine has been started, so legacy paths that
  fire pinChange unconditionally can't crash the simulator.

NgSpiceClient interface added and exported so unit tests can inject a
fake engine that records alter() calls and returns canned readVec
values — `__setSchedulerEngineFactoryForTests`. 7 new tests cover the
load → resolve → alter → republish loop end-to-end without booting
the real WASM worker.

The orchestration layer (Zustand subscriber / DynamicComponent hook)
that calls `loadCircuit` whenever the canvas changes is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-15 17:11:10 +02:00
parent 1ab294cf10
commit 61b04e46f8
2 changed files with 315 additions and 13 deletions

View File

@ -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<string, number> } = {}): {
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);
});
});

View File

@ -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<void>;
loadNetlist(netlist: string): Promise<void>;
command(cmd: string): Promise<{ rc: number; stdout: string[]; stderr: string[] }>;
alter(sourceName: string, dcValue: number): Promise<unknown>;
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<SubscriptionToken, NodeSubscription>();
private voltages = new Map<string, number>();
/** `${componentId}:${pinName}` → SPICE net name (from NetlistBuilder). */
private pinNetMap = new Map<string, string>();
private running = false;
private initPromise: Promise<void> | null = null;
@ -95,16 +118,68 @@ class MixedModeSchedulerImpl implements SpiceVoltageSource {
async start(): Promise<void> {
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<string, string>): Promise<void> {
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<void> {
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_<board>_<pin> dc <value>` + 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_<board>_<pin> dc <voltage>` 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<void> {
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;