feat(sim): Phase 1d #10 + #11 + #16 — observable + perf + UX touch-ups

#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) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-15 22:34:29 +02:00
parent 6c6dea3326
commit 37f35488c7
3 changed files with 87 additions and 5 deletions

View File

@ -109,6 +109,9 @@ const ADC_PIN_TO_GPIO: Partial<Record<BoardKind, (pinName: string, channel: numb
export function connectAnalogInputsToMcu(): () => void {
const patchedAdcs = new WeakSet<object>();
const qemuWaveformChannels = new Set<string>();
// Phase 1d #10 — warn-once set so ADC-clip messages don't spam the
// console. Key is `${boardId}:${pinName}`.
const clipWarned = new Set<string>();
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);

View File

@ -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<string> {
const { pinNetMap } = useElectricalStore.getState();
const pins = new Set<string>();
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<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.
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();
}

View File

@ -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();