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:
parent
1ab294cf10
commit
61b04e46f8
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue