fix(simulator): circuitVerifier worst-case GPIO + LED NaN guard

Two related correctness fixes that make the simulator's realism
match what users actually see.

1. circuitVerifier was running pre-flight against the IDLE circuit
   (every pin LOW). A Blink sketch is going to write pin 13 HIGH
   eventually — at which point a missing series resistor produces a
   ~500 mA spike through the diode. But because pre-flight ran with
   pin 13 LOW the led-overcurrent rule never fired, and the user
   sailed through Run only to see the LED stay mysteriously dark on
   the canvas.

   The verifier now forces every digital pin connected to a load to
   HIGH = vcc, the worst case any well-defined sketch will eventually
   impose. The existing rules (led-overcurrent, resistor-overpower,
   short-circuit) now fire correctly and the existing
   CircuitVerificationModal blocks Run until the user adds a proper
   current limiter or chooses Run Anyway.

   Pins that are inputs-only (a pull-up + button) get over-driven
   here too, but the rules tolerate that — a pull-up at 5 V draws
   ~0.5 mA, well below all thresholds. A circuit that would actually
   fault under HIGH is flagged.

2. LED simulator was crashing visually on non-finite ngspice branch
   currents. A degenerate diode (no series R) makes ngspice return
   NaN, which fell through 'raw !== undefined && current > 1e-6' as
   false and never triggered the digital fallback. Now we check
   Number.isFinite(raw) before trusting it — non-finite returns
   route to the digital fallback so the LED at least lights visually
   when its driver pin is HIGH (the user still sees the verifier
   warning that the real-world circuit is wrong, but Run Anyway is
   not a black screen).
This commit is contained in:
David Montero Crespo 2026-05-17 23:41:19 -03:00
parent 8a6d72aa50
commit 06d6e0afbb
2 changed files with 40 additions and 20 deletions

View File

@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useEditorStore } from '../../store/useEditorStore'; import { useEditorStore } from '../../store/useEditorStore';
import { useSimulatorStore, getBoardPinManager } from '../../store/useSimulatorStore'; import { useSimulatorStore } from '../../store/useSimulatorStore';
import { useElectricalStore } from '../../store/useElectricalStore'; import { useElectricalStore } from '../../store/useElectricalStore';
import { verifyCircuit, type VerificationResult } from '../../simulation/verify/circuitVerifier'; import { verifyCircuit, type VerificationResult } from '../../simulation/verify/circuitVerifier';
import { buildInputFromStore } from '../../simulation/spice/storeAdapter'; import { buildInputFromStore } from '../../simulation/spice/storeAdapter';
@ -310,25 +310,36 @@ export const EditorToolbar = ({
})), })),
wires: sim.wires, wires: sim.wires,
boards: sim.boards.map((b) => { boards: sim.boards.map((b) => {
// Realistic pre-flight: simulate the WORST CASE — every digital
// pin connected to a load is forced HIGH at the board's vcc.
// This is what we want because the user's sketch WILL eventually
// do `digitalWrite(pin, HIGH)` (otherwise why is the LED wired?).
// Testing idle state would never flag a missing series resistor
// because the LED draws zero current when its pin is LOW.
//
// Caveat: pins wired only to inputs (e.g. a pull-up resistor +
// button) get over-driven here too. The verifier rules are
// already tolerant — a properly-spec'd pull-up sees minimal
// current and doesn't trip overcurrent / overpower. A circuit
// that would actually fault under HIGH is flagged correctly.
const pinStates: Record<string, PinSourceState> = {}; const pinStates: Record<string, PinSourceState> = {};
const pm = getBoardPinManager(b.id);
const group = BOARD_PIN_GROUPS[b.boardKind] ?? BOARD_PIN_GROUPS.default; const group = BOARD_PIN_GROUPS[b.boardKind] ?? BOARD_PIN_GROUPS.default;
if (pm) { const wiredPinNames = new Set<string>();
const pinNames = new Set<string>(); for (const w of sim.wires) {
for (const w of sim.wires) { if (w.start.componentId === b.id) wiredPinNames.add(w.start.pinName);
if (w.start.componentId === b.id) pinNames.add(w.start.pinName); if (w.end.componentId === b.id) wiredPinNames.add(w.end.pinName);
if (w.end.componentId === b.id) pinNames.add(w.end.pinName); }
} for (const pinName of wiredPinNames) {
for (const pinName of pinNames) { // Skip GND / power-rail pin names — they belong to the rail
// No mapping table here — verification is a best-effort // groups and don't need to be re-asserted as digital sources.
// snapshot; pins we can't identify just stay floating, which if (group.gnd.includes(pinName)) continue;
// matches their pre-Run state anyway. if (group.vcc_pins.includes(pinName)) continue;
const arduinoPin = Number.parseInt(pinName, 10); const arduinoPin = Number.parseInt(pinName, 10);
if (Number.isNaN(arduinoPin)) continue; // Skip pins we can't identify as a digital GPIO (e.g.
if (pm.getPinState(arduinoPin)) { // 'AREF', 'RESET', 'TX', 'RX' on some boards). Those are
pinStates[pinName] = { type: 'digital', v: group.vcc }; // either rail-ish or non-driven by the sketch.
} if (Number.isNaN(arduinoPin)) continue;
} pinStates[pinName] = { type: 'digital', v: group.vcc };
} }
return { id: b.id, boardKind: b.boardKind, pinStates }; return { id: b.id, boardKind: b.boardKind, pinStates };
}), }),

View File

@ -196,7 +196,16 @@ PartSimulationRegistry.register('led', {
raw = sum / samples.length; raw = sum / samples.length;
} }
} }
if (raw !== undefined) { // Guard against NaN / Infinity coming back from ngspice. They
// happen on degenerate circuits (a forward-biased diode with no
// series resistor — the textbook "missing 220Ω" mistake — is
// the most common case). Without this guard the LED would mark
// `el.value = NaN > 1e-6 = false` and stay visually dark even
// when the user clicks "Run Anyway" past the verifier warning.
// Treat non-finite branch currents as "SPICE has nothing useful
// to say" → fall through to the digital fallback so at least the
// LED visually lights when its driver pin is HIGH.
if (raw !== undefined && Number.isFinite(raw)) {
const current = Math.abs(raw); const current = Math.abs(raw);
lastSpiceBrightness = Math.min(1, current / 0.02); lastSpiceBrightness = Math.min(1, current / 0.02);
lastSpiceTs = Date.now(); lastSpiceTs = Date.now();
@ -204,7 +213,7 @@ PartSimulationRegistry.register('led', {
el.brightness = lastSpiceBrightness; el.brightness = lastSpiceBrightness;
return; return;
} }
if (Date.now() - lastSpiceTs < HOLD_MS && lastSpiceTs > 0) { if (Date.now() - lastSpiceTs < HOLD_MS && lastSpiceTs > 0 && Number.isFinite(lastSpiceBrightness)) {
el.value = lastSpiceBrightness > 1e-3; el.value = lastSpiceBrightness > 1e-3;
el.brightness = lastSpiceBrightness; el.brightness = lastSpiceBrightness;
return; return;