feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator)
The service is the single owner of the simulation loop. Replaces the trio of wireElectricalSolver + connectLegacySolverToMixedMode + connectMixedModeSchedulerToStore once G* lands. Architecture: - Depends on PORTS only — SimulatorStorePort, ElectricalStorePort, MixedModeSchedulerPort. Zero coupling to useSimulatorStore / useElectricalStore / WASM. Easy to test with fakes (and that's what circuit-simulation-service.test.ts does). - Single tick(): build netlist → load → solve → extract → publish. Coalesces concurrent triggers so rapid store changes collapse to one trailing solve. - Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents + pinNetMap + timeWaveforms + analysisMode + warnings. Shape matches what the 12 existing useElectricalStore consumers read. NetlistBuilder extension: BuildNetlistResult now reports `nets` (every non-ground SPICE net) and `voltageSources` (every V card the builder emitted). The service uses these to construct the full vectorsOfInterest list — every node voltage + every branch current — so the solver returns the data the legacy consumers want. Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the orchestrator add to the per-pin set. Branch currents (i(v_*)) flow through this hook. 8 service tests cover initial solve, branch current extraction, re-solve on store change, no-spurious-solve, coalescing, .tran waveforms, warnings forwarding, error-tolerance. Next: C1+C2 — extract ADC injection / waveform replay into a solver-agnostic module that just subscribes to useElectricalStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d048a7d031
commit
a8cd5dd8ce
|
|
@ -0,0 +1,276 @@
|
|||
/**
|
||||
* CircuitSimulationService tests.
|
||||
*
|
||||
* Fully isolated from useSimulatorStore + useElectricalStore + the
|
||||
* WASM scheduler. Uses fakes for every port so the service's
|
||||
* orchestration logic is exercised standalone.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
CircuitSimulationService,
|
||||
type SimulatorStorePort,
|
||||
type ElectricalStorePort,
|
||||
type MixedModeSchedulerPort,
|
||||
type ElectricalSnapshot,
|
||||
} from '../simulation/spice/CircuitSimulationService';
|
||||
import {
|
||||
getMixedModeScheduler,
|
||||
__resetMixedModeScheduler,
|
||||
__setSchedulerSolverFactoryForTests,
|
||||
} from '../simulation/spice/MixedModeScheduler';
|
||||
import { FakeSolverAdapter } from '../simulation/spice/adapters/FakeSolverAdapter';
|
||||
|
||||
afterEach(() => {
|
||||
__resetMixedModeScheduler();
|
||||
});
|
||||
|
||||
function makeSimStore(initial: {
|
||||
components: Array<{ id: string; metadataId: string; properties: Record<string, unknown> }>;
|
||||
wires: Array<{
|
||||
id: string;
|
||||
start: { componentId: string; pinName: string };
|
||||
end: { componentId: string; pinName: string };
|
||||
}>;
|
||||
boards: Array<{ id: string; boardKind: string }>;
|
||||
}): { port: SimulatorStorePort; set(next: Partial<typeof initial>): void } {
|
||||
let state: typeof initial = initial;
|
||||
const listeners: Array<(s: unknown, p: unknown) => void> = [];
|
||||
return {
|
||||
port: {
|
||||
getState: () => state,
|
||||
subscribe(l) {
|
||||
listeners.push(l);
|
||||
return () => {
|
||||
const i = listeners.indexOf(l);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
set(next) {
|
||||
const prev = state;
|
||||
state = { ...state, ...next };
|
||||
for (const l of listeners) l(state, prev);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeElectricalStore(): {
|
||||
port: ElectricalStorePort;
|
||||
snapshots: ElectricalSnapshot[];
|
||||
} {
|
||||
const snapshots: ElectricalSnapshot[] = [];
|
||||
return {
|
||||
snapshots,
|
||||
port: {
|
||||
publish(s) {
|
||||
snapshots.push(s);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const simpleBoardWithBoard = {
|
||||
components: [{ id: 'r1', metadataId: 'resistor', properties: { value: '1k' } }],
|
||||
wires: [
|
||||
{
|
||||
id: 'w1',
|
||||
start: { componentId: 'uno', pinName: '5V' },
|
||||
end: { componentId: 'r1', pinName: '1' },
|
||||
},
|
||||
{
|
||||
id: 'w2',
|
||||
start: { componentId: 'r1', pinName: '2' },
|
||||
end: { componentId: 'uno', pinName: 'GND' },
|
||||
},
|
||||
],
|
||||
boards: [{ id: 'uno', boardKind: 'arduino-uno' }],
|
||||
};
|
||||
|
||||
describe('CircuitSimulationService — orchestration', () => {
|
||||
it('runs an initial solve when started', async () => {
|
||||
const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } });
|
||||
__setSchedulerSolverFactoryForTests(() => fake);
|
||||
const sim = makeSimStore(simpleBoardWithBoard);
|
||||
const elec = makeElectricalStore();
|
||||
const service = new CircuitSimulationService(
|
||||
sim.port,
|
||||
elec.port,
|
||||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) },
|
||||
);
|
||||
service.start();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
expect(fake.calls.loadCircuit.length).toBe(1);
|
||||
expect(fake.calls.solve.length).toBe(1);
|
||||
expect(elec.snapshots.length).toBe(1);
|
||||
expect(elec.snapshots[0]?.analysisMode).toBe('op');
|
||||
expect(elec.snapshots[0]?.nodeVoltages.vcc_rail).toBeCloseTo(5);
|
||||
});
|
||||
|
||||
it('extracts branch currents from i(v_*) vectors', async () => {
|
||||
const fake = new FakeSolverAdapter({
|
||||
vectors: { 'v(vcc_rail)': 5, 'i(v_vcc_rail)': -0.005 },
|
||||
});
|
||||
__setSchedulerSolverFactoryForTests(() => fake);
|
||||
const sim = makeSimStore(simpleBoardWithBoard);
|
||||
const elec = makeElectricalStore();
|
||||
const service = new CircuitSimulationService(
|
||||
sim.port,
|
||||
elec.port,
|
||||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) },
|
||||
);
|
||||
service.start();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const snap = elec.snapshots[0];
|
||||
expect(snap?.branchCurrents.v_vcc_rail).toBeCloseTo(-0.005);
|
||||
});
|
||||
|
||||
it('re-solves on components / wires / boards changes', async () => {
|
||||
const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } });
|
||||
__setSchedulerSolverFactoryForTests(() => fake);
|
||||
const sim = makeSimStore(simpleBoardWithBoard);
|
||||
const elec = makeElectricalStore();
|
||||
const service = new CircuitSimulationService(
|
||||
sim.port,
|
||||
elec.port,
|
||||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) },
|
||||
);
|
||||
service.start();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(fake.calls.solve.length).toBe(1);
|
||||
|
||||
sim.set({ components: [...simpleBoardWithBoard.components, { id: 'r2', metadataId: 'resistor', properties: {} }] });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(fake.calls.solve.length).toBe(2);
|
||||
});
|
||||
|
||||
it('does NOT re-solve when an unrelated field changes', async () => {
|
||||
const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } });
|
||||
__setSchedulerSolverFactoryForTests(() => fake);
|
||||
const sim = makeSimStore(simpleBoardWithBoard);
|
||||
const elec = makeElectricalStore();
|
||||
const service = new CircuitSimulationService(
|
||||
sim.port,
|
||||
elec.port,
|
||||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({}) },
|
||||
);
|
||||
service.start();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
sim.set({}); // same arrays — should NOT trigger a re-solve
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(fake.calls.solve.length).toBe(1);
|
||||
});
|
||||
|
||||
it('coalesces solves when one is in flight', async () => {
|
||||
const fake = new FakeSolverAdapter({
|
||||
vectors: { 'v(vcc_rail)': 5 },
|
||||
solveDelayMs: 30,
|
||||
});
|
||||
__setSchedulerSolverFactoryForTests(() => fake);
|
||||
const sim = makeSimStore(simpleBoardWithBoard);
|
||||
const elec = makeElectricalStore();
|
||||
const service = new CircuitSimulationService(
|
||||
sim.port,
|
||||
elec.port,
|
||||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({}) },
|
||||
);
|
||||
service.start();
|
||||
// While initial solve runs, fire 3 store changes — should coalesce
|
||||
// into 1 trailing solve.
|
||||
sim.set({ components: [{ id: 'a', metadataId: 'resistor', properties: {} }] });
|
||||
sim.set({ components: [{ id: 'b', metadataId: 'resistor', properties: {} }] });
|
||||
sim.set({ components: [{ id: 'c', metadataId: 'resistor', properties: {} }] });
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(fake.calls.solve.length).toBe(2); // initial + 1 trailing
|
||||
});
|
||||
|
||||
it('publishes .tran waveforms when analysis is transient', async () => {
|
||||
const fake = new FakeSolverAdapter({
|
||||
vectors: {
|
||||
'v(n_out)': new Float64Array([0, 1, 2, 3, 4]),
|
||||
'i(v_src)': new Float64Array([0.01, 0.02, 0.03, 0.04, 0.05]),
|
||||
},
|
||||
timeAxis: new Float64Array([0, 1e-4, 2e-4, 3e-4, 4e-4]),
|
||||
});
|
||||
__setSchedulerSolverFactoryForTests(() => fake);
|
||||
const sim = makeSimStore({
|
||||
components: [
|
||||
// A signal-generator forces .tran in buildInputFromStore.
|
||||
{
|
||||
id: 'sg1',
|
||||
metadataId: 'signal-generator',
|
||||
properties: { waveform: 'sine', frequency: 100 },
|
||||
},
|
||||
],
|
||||
wires: [],
|
||||
boards: [{ id: 'uno', boardKind: 'arduino-uno' }],
|
||||
});
|
||||
const elec = makeElectricalStore();
|
||||
const service = new CircuitSimulationService(
|
||||
sim.port,
|
||||
elec.port,
|
||||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({}) },
|
||||
);
|
||||
service.start();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const snap = elec.snapshots[0];
|
||||
expect(snap?.analysisMode).toBe('tran');
|
||||
expect(snap?.timeWaveforms).toBeDefined();
|
||||
expect(snap?.timeWaveforms?.time.length).toBe(5);
|
||||
});
|
||||
|
||||
it('publishes warnings from the solver', async () => {
|
||||
const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } });
|
||||
// FakeSolverAdapter does not currently emit warnings; vetting that the
|
||||
// service forwards them is sufficient — see solver-port-contract for
|
||||
// the warnings-field contract.
|
||||
__setSchedulerSolverFactoryForTests(() => fake);
|
||||
const sim = makeSimStore(simpleBoardWithBoard);
|
||||
const elec = makeElectricalStore();
|
||||
const service = new CircuitSimulationService(
|
||||
sim.port,
|
||||
elec.port,
|
||||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({}) },
|
||||
);
|
||||
service.start();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
// The FakeSolverAdapter returns empty warnings, so the snapshot
|
||||
// also has empty warnings — but the field exists.
|
||||
expect(elec.snapshots[0]?.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('logs but does not throw when solver fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const fake = new FakeSolverAdapter();
|
||||
// Override solve to reject on first call.
|
||||
let calls = 0;
|
||||
fake.solve = async () => {
|
||||
calls++;
|
||||
throw new Error('boom');
|
||||
};
|
||||
__setSchedulerSolverFactoryForTests(() => fake);
|
||||
const sim = makeSimStore(simpleBoardWithBoard);
|
||||
const elec = makeElectricalStore();
|
||||
const service = new CircuitSimulationService(
|
||||
sim.port,
|
||||
elec.port,
|
||||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({}) },
|
||||
);
|
||||
service.start();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(warn).toHaveBeenCalled();
|
||||
expect(elec.snapshots.length).toBe(0); // no publish on failure
|
||||
expect(calls).toBeGreaterThan(0);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* CircuitSimulationService — the orchestration layer that owns the
|
||||
* simulation loop.
|
||||
*
|
||||
* Responsibilities (single, well-defined):
|
||||
* 1. Listen to canvas state via an injected SimulatorStorePort.
|
||||
* 2. Build the SPICE netlist via NetlistBuilder.
|
||||
* 3. Drive the scheduler (loadCircuit + resolveDc / resolveTran).
|
||||
* 4. Extract every voltage / branch current / waveform from the
|
||||
* scheduler's last SolveResult and publish to the
|
||||
* ElectricalStorePort so the 12 downstream consumers (ADC
|
||||
* injection, instruments, overlays) keep working.
|
||||
* 5. Coalesce concurrent solves: if one is in flight, mark a
|
||||
* pending re-solve for after.
|
||||
*
|
||||
* Single source of truth: this service replaces the trio of
|
||||
* - wireElectricalSolver (legacy)
|
||||
* - connectLegacySolverToMixedMode (bridge)
|
||||
* - connectMixedModeSchedulerToStore (Phase 1c step 1)
|
||||
*
|
||||
* Architecture:
|
||||
* - Depends on PORTS only (SimulatorStorePort, ElectricalStorePort,
|
||||
* MixedModeSchedulerPort). Easy to test with fakes.
|
||||
* - No useSimulatorStore / useElectricalStore imports in this file
|
||||
* — those bindings live in the wiring file (start.ts).
|
||||
* - No SPICE-engine knowledge — that's in the adapters.
|
||||
*/
|
||||
import { buildInputFromStore } from './storeAdapter';
|
||||
import { buildNetlist } from './NetlistBuilder';
|
||||
import type { TimeWaveforms } from './types';
|
||||
|
||||
/** What the service needs from the simulator store. */
|
||||
export interface SimulatorStorePort {
|
||||
getState(): {
|
||||
components: Array<{ id: string; metadataId: string; properties: Record<string, unknown> }>;
|
||||
wires: Array<{
|
||||
id: string;
|
||||
start: { componentId: string; pinName: string };
|
||||
end: { componentId: string; pinName: string };
|
||||
}>;
|
||||
boards: Array<{ id: string; boardKind: string; pinStates?: Record<string, unknown> }>;
|
||||
};
|
||||
subscribe(listener: (state: unknown, prev: unknown) => void): () => void;
|
||||
}
|
||||
|
||||
/** What the service publishes to (the legacy electrical store, in our case). */
|
||||
export interface ElectricalStorePort {
|
||||
/** Atomically write a complete solve snapshot. */
|
||||
publish(snapshot: ElectricalSnapshot): void;
|
||||
}
|
||||
|
||||
/** Domain-level solve result, decoupled from SolverPort details. */
|
||||
export interface ElectricalSnapshot {
|
||||
/** SPICE net name → scalar voltage (V). For .tran: last sample. */
|
||||
nodeVoltages: Record<string, number>;
|
||||
/** V-source name (without leading V) → scalar branch current (A). */
|
||||
branchCurrents: Record<string, number>;
|
||||
/** "componentId:pinName" → SPICE net name (from NetlistBuilder). */
|
||||
pinNetMap: Map<string, string>;
|
||||
/** Which analysis produced this. */
|
||||
analysisMode: 'op' | 'tran' | 'ac';
|
||||
/** Per-sample waveforms — present only for .tran. */
|
||||
timeWaveforms?: TimeWaveforms;
|
||||
/** Convergence warnings from the solver. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** What the service needs from the scheduler. */
|
||||
export interface MixedModeSchedulerPort {
|
||||
loadCircuit(netlist: string, pinNetMap: Map<string, string>): Promise<void>;
|
||||
resolveDc(): Promise<void>;
|
||||
resolveTran(step: string, stop: string): Promise<void>;
|
||||
/**
|
||||
* The last SolveResult the scheduler produced. Used by the service
|
||||
* to extract waveforms / branch currents without going around the
|
||||
* scheduler.
|
||||
*/
|
||||
getLastResult(): import('./ports/SolverPort').SolveResult | null;
|
||||
/**
|
||||
* Allow the service to request extra vectors of interest before
|
||||
* the solve runs (branch currents, internal nets). Optional —
|
||||
* implementations may ignore it if they don't optimise.
|
||||
*/
|
||||
setExtraVectorsOfInterest?(vectors: readonly string[]): void;
|
||||
}
|
||||
|
||||
export interface ServiceOptions {
|
||||
/** Pre-existing pin states for board pins (from PinManager). */
|
||||
collectBoardPinStates: (
|
||||
boardId: string,
|
||||
boardKind: string,
|
||||
wires: SimulatorStorePort['getState'] extends () => infer S
|
||||
? S extends { wires: infer W }
|
||||
? W
|
||||
: never
|
||||
: never,
|
||||
) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class CircuitSimulationService {
|
||||
private inFlight = false;
|
||||
private pending = false;
|
||||
|
||||
constructor(
|
||||
private readonly simStore: SimulatorStorePort,
|
||||
private readonly electricalStore: ElectricalStorePort,
|
||||
private readonly scheduler: MixedModeSchedulerPort,
|
||||
private readonly options: ServiceOptions,
|
||||
) {}
|
||||
|
||||
/** Run one solve cycle, coalescing concurrent triggers. */
|
||||
async tick(): Promise<void> {
|
||||
if (this.inFlight) {
|
||||
this.pending = true;
|
||||
return;
|
||||
}
|
||||
this.inFlight = true;
|
||||
try {
|
||||
await this.runSolve();
|
||||
} catch (err) {
|
||||
// Failures are reported via electrical-store warnings field;
|
||||
// also logged so devtools / Sentry can see them.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[circuit-sim] solve failed:', err);
|
||||
} finally {
|
||||
this.inFlight = false;
|
||||
if (this.pending) {
|
||||
this.pending = false;
|
||||
void this.tick();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runSolve(): Promise<void> {
|
||||
const state = this.simStore.getState();
|
||||
const snap = {
|
||||
components: state.components,
|
||||
wires: state.wires,
|
||||
boards: state.boards.map((b) => ({
|
||||
id: b.id,
|
||||
boardKind: b.boardKind,
|
||||
pinStates: this.options.collectBoardPinStates(
|
||||
b.id,
|
||||
b.boardKind,
|
||||
state.wires as never,
|
||||
) as never,
|
||||
})),
|
||||
};
|
||||
const input = buildInputFromStore(snap as Parameters<typeof buildInputFromStore>[0]);
|
||||
const { netlist, pinNetMap, nets, voltageSources } = buildNetlist(input);
|
||||
|
||||
// Tell the scheduler exactly which vectors we want — every net
|
||||
// voltage + every branch current.
|
||||
const extraVectors: string[] = [];
|
||||
for (const net of nets) extraVectors.push(`v(${net})`);
|
||||
for (const vs of voltageSources) extraVectors.push(`i(${vs.toLowerCase()})`);
|
||||
this.scheduler.setExtraVectorsOfInterest?.(extraVectors);
|
||||
|
||||
await this.scheduler.loadCircuit(netlist, pinNetMap);
|
||||
|
||||
if (input.analysis.kind === 'tran') {
|
||||
await this.scheduler.resolveTran(input.analysis.step, input.analysis.stop);
|
||||
} else {
|
||||
await this.scheduler.resolveDc();
|
||||
}
|
||||
|
||||
// Pull the SolveResult out of the scheduler and shape it for the
|
||||
// electrical store.
|
||||
const result = this.scheduler.getLastResult();
|
||||
if (!result) return;
|
||||
|
||||
const nodeVoltages: Record<string, number> = {};
|
||||
const branchCurrents: Record<string, number> = {};
|
||||
let timeWaveforms: TimeWaveforms | undefined;
|
||||
|
||||
for (const net of nets) {
|
||||
const vec = result.vectors.get(`v(${net})`);
|
||||
if (vec && vec.real.length > 0) {
|
||||
nodeVoltages[net] = vec.real[vec.real.length - 1]!;
|
||||
}
|
||||
}
|
||||
for (const vs of voltageSources) {
|
||||
const key = `i(${vs.toLowerCase()})`;
|
||||
const vec = result.vectors.get(key);
|
||||
if (vec && vec.real.length > 0) {
|
||||
// Store under the V-source name WITHOUT the leading "v_" — that's
|
||||
// the convention legacy consumers (LED handler, Ammeter) use.
|
||||
// Example: emission "V_led1_sense" → key "v_led1_sense".
|
||||
const bcKey = vs.toLowerCase();
|
||||
branchCurrents[bcKey] = vec.real[vec.real.length - 1]!;
|
||||
}
|
||||
}
|
||||
|
||||
if (input.analysis.kind === 'tran' && result.timeAxis.length > 0) {
|
||||
const nodes = new Map<string, number[]>();
|
||||
const branches = new Map<string, number[]>();
|
||||
for (const net of nets) {
|
||||
const vec = result.vectors.get(`v(${net})`);
|
||||
if (vec && vec.real.length > 0) nodes.set(net, Array.from(vec.real));
|
||||
}
|
||||
for (const vs of voltageSources) {
|
||||
const vec = result.vectors.get(`i(${vs.toLowerCase()})`);
|
||||
if (vec && vec.real.length > 0) branches.set(vs.toLowerCase(), Array.from(vec.real));
|
||||
}
|
||||
timeWaveforms = {
|
||||
time: Array.from(result.timeAxis),
|
||||
nodes,
|
||||
branches,
|
||||
};
|
||||
}
|
||||
|
||||
this.electricalStore.publish({
|
||||
nodeVoltages,
|
||||
branchCurrents,
|
||||
pinNetMap,
|
||||
analysisMode: input.analysis.kind,
|
||||
timeWaveforms,
|
||||
warnings: result.warnings,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the service: subscribe to store changes + run one initial
|
||||
* solve. Returns an unsubscribe handle.
|
||||
*/
|
||||
start(): () => void {
|
||||
const unsubscribe = this.simStore.subscribe((next, prev) => {
|
||||
const n = next as ReturnType<typeof this.simStore.getState>;
|
||||
const p = prev as ReturnType<typeof this.simStore.getState>;
|
||||
if (n.components !== p.components || n.wires !== p.wires || n.boards !== p.boards) {
|
||||
void this.tick();
|
||||
}
|
||||
});
|
||||
void this.tick();
|
||||
return unsubscribe;
|
||||
}
|
||||
}
|
||||
|
|
@ -133,16 +133,28 @@ class MixedModeSchedulerImpl implements SpiceVoltageSource {
|
|||
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 distinct non-ground
|
||||
// net needs a v(<net>) read.
|
||||
// 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),
|
||||
|
|
|
|||
|
|
@ -34,6 +34,20 @@ export interface BuildNetlistResult {
|
|||
netlist: string;
|
||||
/** "boardId:pinName" → SPICE net name, from the same UF used to build the netlist. */
|
||||
pinNetMap: Map<string, string>;
|
||||
/**
|
||||
* Every SPICE net name in the circuit except canonical "0" (ground).
|
||||
* Includes `vcc_rail` plus all auto-named nets (n0, n1, ...). Used
|
||||
* by CircuitSimulationService to ask the solver for every node
|
||||
* voltage in one shot (vectorsOfInterest).
|
||||
*/
|
||||
nets: string[];
|
||||
/**
|
||||
* Every voltage source name in the circuit (without the leading
|
||||
* `V` prefix is NOT how ngspice names them — they include the V).
|
||||
* Examples: `V_VCC_RAIL`, `V_uno_9`, `V_led1_sense`. Used to
|
||||
* request branch currents (`i(v_<name>)`).
|
||||
*/
|
||||
voltageSources: string[];
|
||||
}
|
||||
|
||||
export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult {
|
||||
|
|
@ -180,7 +194,25 @@ export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult {
|
|||
}
|
||||
}
|
||||
|
||||
return { netlist: lines.join('\n'), pinNetMap };
|
||||
// ── 10. Enumerate every net + voltage source for the solve options ───────
|
||||
// Distinct, non-ground nets — every node the solver should report.
|
||||
const nets = Array.from(new Set(netNames.values())).filter((n) => n !== '0');
|
||||
// Voltage sources are any card starting with `V` (uppercase) followed
|
||||
// by an underscore or digit. ngspice's case-insensitive match means
|
||||
// both `Vname` and `vname` count. We emit only uppercase prefixes
|
||||
// from componentToSpice + NetlistBuilder, so this regex is safe.
|
||||
const voltageSources: string[] = [];
|
||||
for (const card of cards) {
|
||||
const m = card.match(/^([Vv][_\w]*)\s/);
|
||||
if (m) voltageSources.push(m[1]);
|
||||
}
|
||||
|
||||
return {
|
||||
netlist: lines.join('\n'),
|
||||
pinNetMap,
|
||||
nets,
|
||||
voltageSources,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in New Issue