289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
/**
|
|
* 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<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;
|
|
|
|
/** True while the scheduler is actively driving the solver. */
|
|
isRunning(): boolean {
|
|
return this.running;
|
|
}
|
|
|
|
/** Lazy-boot the solver (idempotent). */
|
|
async start(): Promise<void> {
|
|
if (this.running) return;
|
|
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 map
|
|
* produced by `NetlistBuilder.buildNetlist`. Replaces any
|
|
* previously loaded circuit; clears the voltage cache.
|
|
*/
|
|
async loadCircuit(netlist: string, pinNetMap: Map<string, string>): Promise<void> {
|
|
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<void> {
|
|
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<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 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<void> {
|
|
const solver = this.solver;
|
|
if (!solver) return;
|
|
|
|
// Build vectorsOfInterest from pinNetMap (every non-ground net)
|
|
// plus whatever the orchestrator added.
|
|
const vectorsOfInterest = new Set<string>();
|
|
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<void> {
|
|
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;
|