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:
parent
5ce99fab5d
commit
5d64654668
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue