From 37f35488c704a9e543094c5089dd4a76e61c3509 Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Fri, 15 May 2026 22:34:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(sim):=20Phase=201d=20#10=20+=20#11=20+=20#?= =?UTF-8?q?16=20=E2=80=94=20observable=20+=20perf=20+=20UX=20touch-ups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #10 — ESP32 ADC clipping warning: `pushEsp32Waveforms` now counts how many samples land outside the 0-3.3 V ADC range. If > 10% of a pin's waveform clips, console.warn once per pin with the observed range. Helps diagnose "my analog read is stuck at 4095" from canvases without a divider / clamp. #11 — PinManager subscriptions scoped to circuit pins. Previously `connectMcuEdgesToService.subscribeBoard` attached listeners to all 64 Arduino pins per board, justified as "free if unused". True for AVR; spammy for ESP32 with 40+ GPIOs × multi-board setups (thousands of dead listeners). Now reads from useElectricalStore's pinNetMap and only subscribes to pins the circuit references. Re-subscribes when pinNetMap changes (new wire added/removed). #16 — `__spiceDebug()` window helper. Restored after the legacy subscribeToStore deletion in Phase 1c. Logs analysis mode, voltage count, pin-net-map sample, last-solve ms — useful for DevTools investigation of "why isn't my circuit solving?" reports. 1461 tests pass. #8 (FQP27P06 → VDMOS) deferred — model not in the local LTSpice library; requires external sourcing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../spice/connectAnalogInputsToMcu.ts | 24 +++++++++- .../spice/connectMcuEdgesToService.ts | 45 +++++++++++++++++-- frontend/src/simulation/spice/start.ts | 23 ++++++++++ 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts b/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts index 1dcb2841..cd24d45e 100644 --- a/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts +++ b/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts @@ -109,6 +109,9 @@ const ADC_PIN_TO_GPIO: Partial void { const patchedAdcs = new WeakSet(); const qemuWaveformChannels = new Set(); + // Phase 1d #10 — warn-once set so ADC-clip messages don't spam the + // console. Key is `${boardId}:${pinName}`. + const clipWarned = new Set(); let replayStartMs = 0; let replayEpochLatched = false; @@ -171,10 +174,29 @@ export function connectAnalogInputsToMcu(): () => void { const samples = net ? timeWaveforms.nodes.get(net) : undefined; if (!samples || samples.length === 0) continue; const u12 = new Uint16Array(samples.length); + // Phase 1d #10 — count clip events to surface ESP32 ADC + // range violations once per pin. If more than 10% of + // samples land outside [0, 3.3] V, warn the user; a + // rectifier without a clamp is the canonical case. + let clipped = 0; + let observedMin = Infinity; + let observedMax = -Infinity; for (let i = 0; i < samples.length; i++) { - const v = Math.max(0, Math.min(3.3, samples[i])); + const s = samples[i]; + if (s < observedMin) observedMin = s; + if (s > observedMax) observedMax = s; + if (s < 0 || s > 3.3) clipped++; + const v = Math.max(0, Math.min(3.3, s)); u12[i] = Math.round((v / 3.3) * 4095); } + const clipKey = `${boardId}:${pinName}`; + if (clipped > samples.length / 10 && !clipWarned.has(clipKey)) { + clipWarned.add(clipKey); + // eslint-disable-next-line no-console + console.warn( + `[adc-clip] ${clipKey}: ${clipped}/${samples.length} samples outside [0, 3.3] V (range ${observedMin.toFixed(2)}…${observedMax.toFixed(2)} V). ESP32 ADC reads will saturate at the rails. Add a divider or clamp if you need the full swing.`, + ); + } const gpioPin = gpioFn(pinName, channel); if (gpioPin < 0) continue; shim.setAdcWaveform(gpioPin, u12, periodNs); diff --git a/frontend/src/simulation/spice/connectMcuEdgesToService.ts b/frontend/src/simulation/spice/connectMcuEdgesToService.ts index f38241b2..ac7bbbfb 100644 --- a/frontend/src/simulation/spice/connectMcuEdgesToService.ts +++ b/frontend/src/simulation/spice/connectMcuEdgesToService.ts @@ -27,6 +27,7 @@ import { useSimulatorStore, getBoardPinManager, } from '../../store/useSimulatorStore'; +import { useElectricalStore } from '../../store/useElectricalStore'; import { BOARD_PIN_GROUPS } from './boardPinGroups'; import type { CircuitSimulationService } from './CircuitSimulationService'; @@ -86,6 +87,27 @@ export function connectMcuEdgesToService(service: CircuitSimulationService): () return String(arduinoPin); } + /** + * Look up which pin names this board actually wires into the SPICE + * netlist. Reads from `pinNetMap` (populated after each solve) so + * we subscribe to ~3-8 pins per board instead of all 64. + * + * Phase 1d #11: previously we subscribed to every Arduino pin 0..63 + * "since unused listeners are free" — true for AVR (8 pins) but + * spammy for ESP32 (40+ GPIOs × multiple boards = thousands of + * dead listeners). Now scoped to pins the circuit references. + */ + function pinsInCircuit(boardId: string): Set { + const { pinNetMap } = useElectricalStore.getState(); + const pins = new Set(); + for (const key of pinNetMap.keys()) { + const idx = key.indexOf(':'); + if (idx < 0) continue; + if (key.slice(0, idx) === boardId) pins.add(key.slice(idx + 1)); + } + return pins; + } + function subscribeBoard(boardId: string, boardKind: string): void { const pm = getBoardPinManager(boardId); if (!pm) return; @@ -95,13 +117,16 @@ export function connectMcuEdgesToService(service: CircuitSimulationService): () 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. + const wanted = pinsInCircuit(boardId); + + // Sweep 0..63 but only attach a listener when the pin name maps + // to one of the wires in the current netlist. Re-subscription + // when the canvas changes happens via `syncBoardSubscriptions` on + // store-level board diffs and on `pinNetMap` updates below. for (let pin = 0; pin < 64; pin++) { const pinName = arduinoPinToName(pin, boardKind); if (!pinName) continue; + if (wanted.size > 0 && !wanted.has(pinName)) continue; const unsub = pm.onPinChange(pin, (_p, state) => { schedulePin(boardId, pinName, state, vcc); }); @@ -133,8 +158,20 @@ export function connectMcuEdgesToService(service: CircuitSimulationService): () if (state.boards !== prev.boards) syncBoardSubscriptions(); }); + // Re-subscribe when the pinNetMap changes — a new wire / removed + // wire might add or drop pins that need listeners. Drop ALL subs + // and re-create from the new pinNetMap (cheap: a Map clear and + // ~10 pm.onPinChange calls). + const unsubElectrical = useElectricalStore.subscribe((state, prev) => { + if (state.pinNetMap === prev.pinNetMap) return; + const boards = useSimulatorStore.getState().boards; + for (const id of Array.from(boardSubs.keys())) unsubscribeBoard(id); + for (const b of boards) subscribeBoard(b.id, b.boardKind); + }); + return () => { unsubBoards(); + unsubElectrical(); for (const pinSubs of boardSubs.values()) { for (const unsub of pinSubs.values()) unsub(); } diff --git a/frontend/src/simulation/spice/start.ts b/frontend/src/simulation/spice/start.ts index 1f84b38a..b9104136 100644 --- a/frontend/src/simulation/spice/start.ts +++ b/frontend/src/simulation/spice/start.ts @@ -81,6 +81,29 @@ export function startSimulation(): () => void { const unsubService = service.start(); const unsubAdc = connectAnalogInputsToMcu(); const unsubEdges = connectMcuEdgesToService(service); + + // Phase 1d #16 — debug helper. Call `__spiceDebug()` from DevTools + // to get a snapshot of the simulation state (analysis mode, voltage + // count, pin map, last solve time, etc.). Useful for diagnosing + // "why is my circuit not solving?" reports from users. + (window as unknown as { __spiceDebug?: () => void }).__spiceDebug = () => { + const electrical = useElectricalStore.getState(); + // eslint-disable-next-line no-console + console.log('[__spiceDebug]', { + analysisMode: electrical.analysisMode, + converged: electrical.converged, + error: electrical.error, + lastSolveMs: electrical.lastSolveMs, + nodeVoltageCount: Object.keys(electrical.nodeVoltages).length, + branchCurrentCount: Object.keys(electrical.branchCurrents).length, + pinNetMapSize: electrical.pinNetMap.size, + hasTimeWaveforms: !!electrical.timeWaveforms, + paused: electrical.paused, + sampleVoltages: Object.entries(electrical.nodeVoltages).slice(0, 8), + pinNetSample: [...electrical.pinNetMap.entries()].slice(0, 8), + }); + }; + return () => { unsubService(); unsubAdc();