velxio/frontend/src/__tests__/circuit-simulation-service....

382 lines
13 KiB
TypeScript
Raw Normal View History

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>
2026-05-16 00:28:26 +07:00
/**
* 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([]);
});
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
});
describe('handleMcuEdge (Phase 1c D1)', () => {
it('runs an initial full solve, then alter + republish on edge', async () => {
let gateV = 0;
let drainV = 4.9;
const fake = new FakeSolverAdapter({
vectors: () => ({
'v(net_gate)': gateV,
'v(net_drain)': drainV,
'v(vcc_rail)': 5,
'i(v_vcc_rail)': -0.005,
}),
});
fake.onAlter = (name, value) => {
if (name === 'V_uno_9') {
gateV = value;
drainV = value >= 1.6 ? 0.05 : 4.9;
}
};
__setSchedulerSolverFactoryForTests(() => fake);
const sim = makeSimStore({
components: [
{ id: 'q1', metadataId: 'bjt-2n2222', properties: {} },
{ id: 'rb', metadataId: 'resistor', properties: { value: '1k' } },
],
wires: [
{
id: 'w1',
start: { componentId: 'uno', pinName: '9' },
end: { componentId: 'rb', pinName: '1' },
},
{
id: 'w2',
start: { componentId: 'rb', pinName: '2' },
end: { componentId: 'q1', pinName: 'B' },
},
],
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 initialSolves = fake.calls.solve.length;
const initialSnapshots = elec.snapshots.length;
expect(initialSolves).toBe(1); // initial full solve
expect(initialSnapshots).toBe(1);
await service.handleMcuEdge('uno', '9', true, 5);
// Solve count went up by exactly 1 (alter + .op), no new loadCircuit.
expect(fake.calls.solve.length).toBe(initialSolves + 1);
expect(fake.calls.loadCircuit.length).toBe(1); // still 1
expect(fake.calls.alterSource).toEqual([['V_uno_9', 5]]);
expect(elec.snapshots.length).toBe(initialSnapshots + 1);
});
it('coalesces an edge with an in-flight full solve', 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 is running, fire an edge.
void service.handleMcuEdge('uno', '9', true, 5);
await new Promise((r) => setTimeout(r, 100));
// After initial solve, the pending edge replays — total solves = 2.
expect(fake.calls.solve.length).toBe(2);
expect(fake.calls.alterSource).toEqual([['V_uno_9', 5]]);
});
it('kicks a full tick when no circuit has been loaded yet', 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: () => ({}) },
);
// No service.start() — first call is handleMcuEdge.
await service.handleMcuEdge('uno', '9', true, 5);
await new Promise((r) => setTimeout(r, 10));
// Should have done a FULL tick (loadCircuit + solve), not just alter.
expect(fake.calls.loadCircuit.length).toBe(1);
expect(fake.calls.alterSource).toEqual([]);
});
});
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>
2026-05-16 00:28:26 +07:00
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
describe('CircuitSimulationService — error handling', () => {
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>
2026-05-16 00:28:26 +07:00
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();
});
});