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:
davidmonterocrespo24 2026-05-15 19:24:12 +02:00
parent 834f8f7e0a
commit d048a7d031
2 changed files with 220 additions and 335 deletions

View File

@ -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();
});
});

View File

@ -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;