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>
This commit is contained in:
davidmonterocrespo24 2026-05-15 20:26:09 +02:00
parent 5ce99fab5d
commit 5d64654668
3 changed files with 347 additions and 24 deletions

View File

@ -247,7 +247,112 @@ describe('CircuitSimulationService — orchestration', () => {
// also has empty warnings — but the field exists.
expect(elec.snapshots[0]?.warnings).toEqual([]);
});
});
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([]);
});
});
describe('CircuitSimulationService — error handling', () => {
it('logs but does not throw when solver fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const fake = new FakeSolverAdapter();

View File

@ -76,6 +76,12 @@ export interface MixedModeSchedulerPort {
* scheduler.
*/
getLastResult(): import('./ports/SolverPort').SolveResult | null;
/**
* MCU pin transition alter the matching V source + re-resolve.
* Domain-level event; the scheduler maps state+vcc volts and
* issues the alter.
*/
onMcuPinChange(boardId: string, pinName: string, state: boolean, vcc: number): Promise<void>;
/**
* Allow the service to request extra vectors of interest before
* the solve runs (branch currents, internal nets). Optional
@ -100,6 +106,20 @@ export interface ServiceOptions {
export class CircuitSimulationService {
private inFlight = false;
private pending = false;
private pendingMcuEdge: { boardId: string; pinName: string; state: boolean; vcc: number } | null =
null;
/**
* Last loaded circuit context used by `handleMcuEdge` to extract
* the right vectors from `scheduler.getLastResult()` after an
* `alter + resolveDc` without rebuilding the netlist.
*/
private loadedContext: {
pinNetMap: Map<string, string>;
nets: string[];
voltageSources: string[];
analysisKind: 'op' | 'tran' | 'ac';
} | null = null;
constructor(
private readonly simStore: SimulatorStorePort,
@ -118,10 +138,52 @@ export class CircuitSimulationService {
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();
} else if (this.pendingMcuEdge) {
const edge = this.pendingMcuEdge;
this.pendingMcuEdge = null;
void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc);
}
}
}
/**
* Handle an MCU pin transition. Uses the WASM solver's
* `alterSource` to update the relevant voltage source in place
* and re-resolve no netlist rebuild then publishes the new
* voltages to useElectricalStore.
*
* Coalesces with the canvas-change tick: if a full solve is in
* flight, the edge is queued and replayed after that solve
* completes (so the netlist is fresh). Last-edge-wins per pin
* since edges overwrite the same field.
*/
async handleMcuEdge(boardId: string, pinName: string, state: boolean, vcc: number): Promise<void> {
if (this.inFlight) {
this.pendingMcuEdge = { boardId, pinName, state, vcc };
return;
}
if (!this.loadedContext) {
// No circuit loaded yet — kick a full tick. The edge will
// appear in board.pinStates during runSolve.
void this.tick();
return;
}
this.inFlight = true;
try {
// alter + resolveDc internally; the scheduler's
// onMcuPinChange covers both steps.
await this.scheduler.onMcuPinChange(boardId, pinName, state, vcc);
this.publishFromLastResult();
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[circuit-sim] mcu-edge solve failed:', err);
} finally {
this.inFlight = false;
if (this.pending) {
@ -164,56 +226,65 @@ export class CircuitSimulationService {
await this.scheduler.resolveDc();
}
// Pull the SolveResult out of the scheduler and shape it for the
// electrical store.
// Cache the load context so handleMcuEdge can publish without
// re-running buildInputFromStore + buildNetlist.
this.loadedContext = {
pinNetMap,
nets,
voltageSources,
analysisKind: input.analysis.kind,
};
this.publishFromLastResult();
}
/**
* Build an ElectricalSnapshot from the scheduler's last SolveResult
* + the cached load context. Publishes to useElectricalStore.
*/
private publishFromLastResult(): void {
const ctx = this.loadedContext;
const result = this.scheduler.getLastResult();
if (!result) return;
if (!ctx || !result) return;
const nodeVoltages: Record<string, number> = {};
const branchCurrents: Record<string, number> = {};
let timeWaveforms: TimeWaveforms | undefined;
for (const net of nets) {
for (const net of ctx.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);
for (const vs of ctx.voltageSources) {
const vec = result.vectors.get(`i(${vs.toLowerCase()})`);
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]!;
// Convention: useElectricalStore.branchCurrents keys use the
// lower-case V-source name (e.g. "v_led1_sense"). LED handler
// and Ammeter both read this shape.
branchCurrents[vs.toLowerCase()] = vec.real[vec.real.length - 1]!;
}
}
if (input.analysis.kind === 'tran' && result.timeAxis.length > 0) {
if (ctx.analysisKind === 'tran' && result.timeAxis.length > 0) {
const nodes = new Map<string, number[]>();
const branches = new Map<string, number[]>();
for (const net of nets) {
for (const net of ctx.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) {
for (const vs of ctx.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,
};
timeWaveforms = { time: Array.from(result.timeAxis), nodes, branches };
}
this.electricalStore.publish({
nodeVoltages,
branchCurrents,
pinNetMap,
analysisMode: input.analysis.kind,
pinNetMap: ctx.pinNetMap,
analysisMode: ctx.analysisKind,
timeWaveforms,
warnings: result.warnings,
});

View File

@ -0,0 +1,147 @@
/**
* connectMcuEdgesToService bridges MCU pin transitions to the
* CircuitSimulationService, completing the mixed-mode loop.
*
* Without this wiring, the service only re-solves on canvas changes
* MCU edges propagate via PinManager component handlers directly,
* but SPICE never sees them. This module:
*
* 1. Subscribes to each board's PinManager for every pin referenced
* by a wire (i.e., pins that appear in the SPICE netlist).
* 2. Coalesces edges per pin (last-state-wins inside a 16 ms
* window) so kHz toggles don't drown the solver.
* 3. Calls `service.handleMcuEdge(boardId, pinName, state, vcc)`
* which alters the corresponding V source + re-resolves +
* publishes the new electrical snapshot.
*
* Why batching here and not in the service:
* - The service is solver-rate (limited by ngspice solve time).
* - PinManager events fire at MCU clock rate (16 MHz simulated).
* - Throttling at the source matches event rates; throttling at the
* service would still queue O(N) edges per ms.
*
* Lifecycle: mount alongside the service in EditorPage. Re-subscribes
* when boards change (board lifecycle = new PinManager instance).
*/
import {
useSimulatorStore,
getBoardPinManager,
} from '../../store/useSimulatorStore';
import { BOARD_PIN_GROUPS } from './boardPinGroups';
import type { CircuitSimulationService } from './CircuitSimulationService';
/** How long edges per pin coalesce. 16 ms 60 fps, well below any
* human-perceptible MCU update rate and above the solver's per-edge
* cost (~5-15 ms for typical netlists). */
const COALESCE_WINDOW_MS = 16;
/**
* Wire MCU pin transitions to the service. Returns an unsubscribe
* handle. Idempotent calling twice double-subscribes; callers
* should hold a single instance per editor mount.
*/
export function connectMcuEdgesToService(service: CircuitSimulationService): () => void {
// Per-board, per-pin subscriptions (Arduino pin number → unsubscribe).
const boardSubs = new Map<string, Map<number, () => void>>();
// Pending coalesced state per pin.
const pending = new Map<string, { state: boolean; vcc: number; pinName: string; timer: ReturnType<typeof setTimeout> | null }>();
function pinKey(boardId: string, pinName: string): string {
return `${boardId}|${pinName}`;
}
function flushPin(boardId: string, pinName: string): void {
const key = pinKey(boardId, pinName);
const entry = pending.get(key);
if (!entry) return;
pending.delete(key);
void service.handleMcuEdge(boardId, pinName, entry.state, entry.vcc);
}
function schedulePin(boardId: string, pinName: string, state: boolean, vcc: number): void {
const key = pinKey(boardId, pinName);
const existing = pending.get(key);
if (existing) {
existing.state = state; // last-state-wins
return;
}
const timer = setTimeout(() => flushPin(boardId, pinName), COALESCE_WINDOW_MS);
pending.set(key, { state, vcc, pinName, timer });
}
function arduinoPinToName(arduinoPin: number, boardKind: string): string | null {
// Reverse of pinNameToArduinoPin in subscribeToStore.ts. Both
// need to live until subscribeToStore is deleted; trade-off
// accepted for now since the mapping is per-board-family.
if (boardKind === 'arduino-uno' || boardKind === 'arduino-nano' || boardKind === 'arduino-mega') {
if (arduinoPin >= 14 && arduinoPin <= 21) return `A${arduinoPin - 14}`;
return String(arduinoPin);
}
if (boardKind === 'raspberry-pi-pico' || boardKind === 'pi-pico-w') {
return `GP${arduinoPin}`;
}
if (boardKind.startsWith('esp32')) {
return `GPIO${arduinoPin}`;
}
return String(arduinoPin);
}
function subscribeBoard(boardId: string, boardKind: string): void {
const pm = getBoardPinManager(boardId);
if (!pm) return;
const group = BOARD_PIN_GROUPS[boardKind as keyof typeof BOARD_PIN_GROUPS] ?? BOARD_PIN_GROUPS.default;
const vcc = group.vcc;
const pinSubs = new Map<number, () => void>();
boardSubs.set(boardId, pinSubs);
// Subscribe to every Arduino pin 0..63 — PinManager only fires
// listeners that match real port events, so unused pins are
// free. We cover digital + analog + RP2040/ESP32 GPIO ranges in
// one sweep.
for (let pin = 0; pin < 64; pin++) {
const pinName = arduinoPinToName(pin, boardKind);
if (!pinName) continue;
const unsub = pm.onPinChange(pin, (_p, state) => {
schedulePin(boardId, pinName, state, vcc);
});
pinSubs.set(pin, unsub);
}
}
function unsubscribeBoard(boardId: string): void {
const pinSubs = boardSubs.get(boardId);
if (!pinSubs) return;
for (const unsub of pinSubs.values()) unsub();
boardSubs.delete(boardId);
}
function syncBoardSubscriptions(): void {
const boards = useSimulatorStore.getState().boards;
const wanted = new Set(boards.map((b) => b.id));
for (const id of Array.from(boardSubs.keys())) {
if (!wanted.has(id)) unsubscribeBoard(id);
}
for (const b of boards) {
if (!boardSubs.has(b.id)) subscribeBoard(b.id, b.boardKind);
}
}
syncBoardSubscriptions();
const unsubBoards = useSimulatorStore.subscribe((state, prev) => {
if (state.boards !== prev.boards) syncBoardSubscriptions();
});
return () => {
unsubBoards();
for (const pinSubs of boardSubs.values()) {
for (const unsub of pinSubs.values()) unsub();
}
boardSubs.clear();
for (const entry of pending.values()) {
if (entry.timer) clearTimeout(entry.timer);
}
pending.clear();
};
}