/** * MixedModeScheduler — voltage event bus + solver orchestrator. * * Architecture (Phase 1c onwards): * * ┌─────────────────────────────┐ * │ 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) │ * └─────────────────────────────┘ * * 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`). * * 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 type { SolverPort, SolveAnalysis } from './ports/SolverPort'; import { NgSpiceWorkerAdapter } from './adapters/NgSpiceWorkerAdapter'; import { sanitizeSpiceId } from './NetlistBuilder'; import type { PinState, SpiceVoltageSource } from '../PinResolver'; /** * Identity of a "pin of interest" — a place a SpiceResolvedPinResolver * is watching for voltage changes. */ export interface NodeSubscription { componentId: string; componentPinName: string; cb: (state: PinState, voltage: number) => void; } type SubscriptionToken = number; /** Voltage cache key = `${componentId}|${componentPinName}`. */ function pinKey(componentId: string, componentPinName: string): string { return `${componentId}|${componentPinName}`; } class MixedModeSchedulerImpl implements SpiceVoltageSource { private solver: SolverPort | null = null; private solverFactory: () => SolverPort = () => new NgSpiceWorkerAdapter(); private nextToken: SubscriptionToken = 1; private subscriptions = new Map(); private voltages = new Map(); /** `${componentId}:${pinName}` → SPICE net name (from NetlistBuilder). */ private pinNetMap = new Map(); private running = false; private initPromise: Promise | null = null; /** True while the scheduler is actively driving the solver. */ isRunning(): boolean { return this.running; } /** Lazy-boot the solver (idempotent). */ async start(): Promise { if (this.running) return; await this.ensureSolver(); this.running = true; } private async ensureSolver(): Promise { 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 map * produced by `NetlistBuilder.buildNetlist`. Replaces any * previously loaded circuit; clears the voltage cache. */ async loadCircuit(netlist: string, pinNetMap: Map): Promise { const solver = await this.ensureSolver(); await solver.loadCircuit(netlist); this.pinNetMap = new Map(pinNetMap); this.voltages.clear(); } /** * 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 { if (!this.solver) { throw new Error('MixedModeScheduler.resolveDc(): call loadCircuit first'); } 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 { 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 extraVectors: readonly string[] = []; /** * Let an orchestrator (CircuitSimulationService) ask the solver * for vectors beyond the pin-net set — branch currents, internal * nets, etc. Replaces any previous set; pass [] to clear. */ setExtraVectorsOfInterest(vectors: readonly string[]): void { this.extraVectors = vectors; } private async solveAndPublish(analysis: SolveAnalysis): Promise { const solver = this.solver; if (!solver) return; // Build vectorsOfInterest from pinNetMap (every non-ground net) // plus whatever the orchestrator added. const vectorsOfInterest = new Set(); for (const net of this.pinNetMap.values()) { if (net !== '0') vectorsOfInterest.add(`v(${net})`); } for (const v of this.extraVectors) vectorsOfInterest.add(v); 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; const componentId = key.slice(0, idx); const pinName = key.slice(idx + 1); if (net === '0') { this.publishVoltage(componentId, pinName, 0); continue; } 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. Engine stays warm so restart is cheap. */ stop(): void { if (!this.running) return; this.running = false; } /** Tear down the solver entirely. */ dispose(): void { this.running = false; 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 voltage events. Implements * `SpiceVoltageSource` so `createSpiceResolvedPinResolver` can use * the scheduler directly. */ subscribe( componentId: string, componentPinName: string, cb: (state: PinState, voltage: number) => void, ): () => void { const token = this.nextToken++; this.subscriptions.set(token, { componentId, componentPinName, cb }); return () => { this.subscriptions.delete(token); }; } /** 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 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) { sub.cb('UNKNOWN' as PinState, voltage); } } } /** * 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, pinName: string, state: boolean, vcc: number, ): Promise { if (!this.solver) return; // Must match the sanitized name NetlistBuilder emits — see comment // at the V-source emission site for why ngspice's `alter` command // can't accept hyphens. const sourceName = `V_${sanitizeSpiceId(boardId)}_${sanitizeSpiceId(pinName)}`; const voltage = state ? vcc : 0; await this.solver.alterSource(sourceName, voltage); await this.resolveDc(); } } /** Singleton accessor. */ let instance: MixedModeSchedulerImpl | null = null; export function getMixedModeScheduler(): MixedModeSchedulerImpl { if (!instance) instance = new MixedModeSchedulerImpl(); return instance; } /** Test helper — drop the singleton so each test starts clean. */ export function __resetMixedModeScheduler(): void { if (instance) instance.dispose(); instance = null; } /** * 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 { solverFactory: () => SolverPort; }; sched.solverFactory = factory; } export type MixedModeScheduler = MixedModeSchedulerImpl;