feat(sim): Phase 1c A4+A5 — scheduler depends on SolverPort
MixedModeScheduler now accepts any SolverPort implementation via solverFactory injection. The ad-hoc `NgSpiceClient` interface is gone; the scheduler talks domain port types only. New capabilities that fell out of the refactor: - `resolveTran(step, stop)` — runs .tran via the solver and publishes the steady-state (last-sample) voltage per pin. Full waveform reachable via `getLastResult()` for downstream consumers (CircuitSimulationService in B1+ will use this to populate useElectricalStore.timeWaveforms). - `getLastResult()` exposes the SolveResult so the upcoming service layer can extract branchCurrents + waveforms without re-reading. - `vectorsOfInterest` is computed from pinNetMap on every solve, so the adapter only issues N parallel readVecs (where N = distinct non-ground nets) instead of guessing. `__setSchedulerEngineFactoryForTests` renamed to `__setSchedulerSolverFactoryForTests`. Tests fully migrated to FakeSolverAdapter — no more inline mock NgSpiceClient. Test layering now mirrors production: scheduler tests exercise port consumption, port-contract tests exercise the port itself. 60 tests pass across mixed-mode-scheduler, solver-port-contract, mixed-mode-bjt-switch-integration (real ngspice), pin-resolver, pin-resolver-phase1b, connect-mixed-mode-scheduler-to-store, connect-legacy-solver-to-mixed-mode. tsc clean. Next: B1 — CircuitSimulationService, the layer above the scheduler that builds netlists, picks .op vs .tran, and publishes results to both useElectricalStore and the scheduler cache. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
834f8f7e0a
commit
d048a7d031
|
|
@ -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<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();
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
* 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<SubscriptionToken, NodeSubscription>();
|
||||
private voltages = new Map<string, number>();
|
||||
|
|
@ -105,63 +67,90 @@ class MixedModeSchedulerImpl implements SpiceVoltageSource {
|
|||
private running = false;
|
||||
private initPromise: Promise<void> | 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<void> {
|
||||
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<SolverPort> {
|
||||
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<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);
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const solver = this.solver;
|
||||
if (!solver) return;
|
||||
|
||||
// Build vectorsOfInterest from pinNetMap — every distinct non-ground
|
||||
// net needs a v(<net>) read.
|
||||
const vectorsOfInterest = new Set<string>();
|
||||
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_<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.
|
||||
*
|
||||
* 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<void> {
|
||||
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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue