diff --git a/frontend/src/__tests__/circuit-simulation-service.test.ts b/frontend/src/__tests__/circuit-simulation-service.test.ts index a5d14d7b..dc99dd4e 100644 --- a/frontend/src/__tests__/circuit-simulation-service.test.ts +++ b/frontend/src/__tests__/circuit-simulation-service.test.ts @@ -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(); diff --git a/frontend/src/simulation/spice/CircuitSimulationService.ts b/frontend/src/simulation/spice/CircuitSimulationService.ts index 60f222cc..5538bd57 100644 --- a/frontend/src/simulation/spice/CircuitSimulationService.ts +++ b/frontend/src/simulation/spice/CircuitSimulationService.ts @@ -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; /** * 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; + 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 { + 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 = {}; const branchCurrents: Record = {}; 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(); const branches = new Map(); - 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, }); diff --git a/frontend/src/simulation/spice/connectMcuEdgesToService.ts b/frontend/src/simulation/spice/connectMcuEdgesToService.ts new file mode 100644 index 00000000..f38241b2 --- /dev/null +++ b/frontend/src/simulation/spice/connectMcuEdgesToService.ts @@ -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 void>>(); + // Pending coalesced state per pin. + const pending = new Map | 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 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(); + }; +}