fix(sim): circuit verifier was silently blind to current faults in prod

The pre-flight circuit verifier reads branch currents via runNetlist ->
readAllCurrentVectors() (ngSpice_AllVecs enumeration). The production
Web-Worker ngspice WASM build does not surface voltage-source #branch
vectors through that enumeration for an .op plot, so branchCurrents came
back empty and every current rule (short-circuit, LED over-current) read
?? 0 -> no fault. The live solver avoided this by requesting each current
explicitly by name; the Node test build enumerates them, so the gap was
invisible to the suite. Net effect: a 9V battery wired straight to an LED
ran with no warning (reported on project 2840fd12).

- runNetlist: request every V_* source branch current explicitly by name
  and merge with the enumeration, so source/LED currents are always present
  regardless of the worker WASM's AllVecs behaviour.
- circuitVerifier: non-finite source/LED current -> blocking unstable-solve
  fault ("could not solve a stable current - likely a short or a part with
  no current limit, e.g. an LED with no series resistor").
- LED runtime (BasicParts): burn out on a non-finite current instead of
  falling through to the digital fallback and glowing; raise burnout
  threshold 20mA -> 100mA so high-power/RGB channels are not falsely
  destroyed; clear the burnt latch on Reset (resetBoard bumps hexEpoch).

Tests: real-data repro, mocked non-finite verifier test, runtime
non-finite / high-power / latch-recovery tests.
This commit is contained in:
David Montero 2026-06-17 20:37:32 +02:00
parent 2d23b878e7
commit 3372151405
7 changed files with 324 additions and 14 deletions

View File

@ -0,0 +1,64 @@
/**
* circuitVerifier the "cannot emulate" path.
*
* When ngspice cannot find a stable operating point it returns NaN/Infinity
* for the offending branch current (the classic case: an LED with no series
* resistor a near-short across the supply). The verifier must surface that
* as a BLOCKING `unstable-solve` fault, NOT silently treat it as 0 A and wave
* the circuit through (the production bug behind the 9VLED report).
*
* The real Node ngspice build always converges this circuit, so we mock the
* solver to deterministically return non-finite currents.
*/
import { describe, it, expect, vi } from 'vitest';
vi.mock('../simulation/spice/runNetlist', () => ({
runNetlist: vi.fn(async () => ({
variableNames: ['v(n0)', 'v(n1)', 'i(v_bat)', 'i(v_led1_sense)'],
dcValue: (name: string) => {
switch (name) {
case 'v(n0)':
return 1.05;
case 'v(n1)':
return -1.05;
case 'i(v_bat)':
return NaN; // source current — no stable solution
case 'i(v_led1_sense)':
return Infinity; // LED forward current — no stable solution
default:
return 0;
}
},
vec: () => [],
vAtLast: () => 0,
findVar: () => -1,
})),
}));
import { verifyCircuit } from '../simulation/verify/circuitVerifier';
import type { BuildNetlistInput } from '../simulation/spice/types';
describe('verifyCircuit — non-finite solve → unstable-solve fault', () => {
it('blocks a 9V battery wired straight to an LED (no resistor)', async () => {
const input: BuildNetlistInput = {
components: [
{ id: 'bat', metadataId: 'battery-9v', properties: {} },
{ id: 'led1', metadataId: 'led', properties: { color: 'red' } },
],
wires: [
{ id: 'w1', start: { componentId: 'bat', pinName: '+' }, end: { componentId: 'led1', pinName: 'A' } },
{ id: 'w2', start: { componentId: 'led1', pinName: 'C' }, end: { componentId: 'bat', pinName: '' } },
],
boards: [],
analysis: { kind: 'op' },
};
const result = await verifyCircuit(input);
const codes = result.errors.map((e) => e.code);
// Both the source and the LED report the unstable solve — either is enough
// to block, and both must be `unstable-solve` (not silently dropped to 0 A).
expect(codes, JSON.stringify(result.errors)).toContain('unstable-solve');
expect(result.errors.length).toBeGreaterThan(0);
const ledFault = result.errors.find((e) => e.componentId === 'led1');
expect(ledFault?.code).toBe('unstable-solve');
});
});

View File

@ -17,6 +17,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry';
import { useElectricalStore } from '../store/useElectricalStore';
// Side-effect imports — register all parts
import '../simulation/parts/BasicParts';
@ -184,6 +185,91 @@ describe('LED — attachEvents (anode + cathode check)', () => {
});
});
// ─── LED overcurrent burnout (SPICE forward current) ──────────────────────────
describe('LED — overcurrent burnout', () => {
afterEach(() => {
useElectricalStore.setState({ branchCurrents: {}, timeWaveforms: undefined });
});
function attachLed(id: string) {
const logic = PartSimulationRegistry.get('led')!;
const el = makeElement({ value: false, brightness: 0 });
const sim = makeSimulator();
logic.attachEvents!(el, sim as any, pinMap({ A: 13, C: -1 }), id);
const anode = sim.pinManager.onPinChange.mock.calls.find((c: any) => c[0] === 13)![1];
return { el: el as any, trigger: () => anode(13, true) };
}
it('burns out (goes dark) at destructive forward current', () => {
const { el, trigger } = attachLed('led-burn');
useElectricalStore.setState({ branchCurrents: { 'v_led-burn_sense': 4.6 } }); // 9V/no resistor
trigger();
expect(el.brightness).toBe(0);
expect(el.value).toBe(false);
// latched: stays dark even if the current later drops to a safe value
useElectricalStore.setState({ branchCurrents: { 'v_led-burn_sense': 0.01 } });
trigger();
expect(el.brightness).toBe(0);
expect(el.value).toBe(false);
});
it('does NOT burn out at a normal bright current (15 mA)', () => {
const { el, trigger } = attachLed('led-ok');
useElectricalStore.setState({ branchCurrents: { 'v_led-ok_sense': 0.015 } });
trigger();
expect(el.value).toBe(true);
expect(el.brightness).toBeCloseTo(0.75, 2); // 15 mA / 20 mA rated
});
it('does NOT burn out just over the rated max (25 mA) — only destructive current', () => {
const { el, trigger } = attachLed('led-warm');
useElectricalStore.setState({ branchCurrents: { 'v_led-warm_sense': 0.025 } });
trigger();
expect(el.value).toBe(true);
expect(el.brightness).toBe(1); // bright (clamped), still alive
});
it('does NOT burn out at a legitimate high-power current (80 mA)', () => {
// High-power / RGB channels can pull ~100-150 mA legitimately; the burnout
// threshold (100 mA) must sit above the bright-but-fine range.
const { el, trigger } = attachLed('led-hp');
useElectricalStore.setState({ branchCurrents: { 'v_led-hp_sense': 0.08 } });
trigger();
expect(el.value).toBe(true);
expect(el.brightness).toBe(1);
});
it('burns out when the solver returns a non-finite current (no-resistor short)', () => {
// A diode straight across a supply with no series resistor often has no
// stable operating point → ngspice returns NaN/Infinity. That must burn
// the LED out, NOT fall through to the digital fallback and glow.
const { el, trigger } = attachLed('led-nan');
useElectricalStore.setState({ branchCurrents: { 'v_led-nan_sense': NaN } });
trigger();
expect(el.value).toBe(false);
expect(el.brightness).toBe(0);
// latched
useElectricalStore.setState({ branchCurrents: { 'v_led-nan_sense': 0.01 } });
trigger();
expect(el.value).toBe(false);
});
it('recovers after a fresh re-attach (Reset bumps hexEpoch → new closure)', () => {
// Burn one instance...
const first = attachLed('led-fix');
useElectricalStore.setState({ branchCurrents: { 'v_led-fix_sense': 4.6 } });
first.trigger();
expect(first.el.value).toBe(false);
// ...fix the circuit, then re-attach (what a Reset does via hexEpoch).
// The new closure starts un-burnt, so the now-safe circuit lights again.
useElectricalStore.setState({ branchCurrents: { 'v_led-fix_sense': 0.015 } });
const second = attachLed('led-fix');
second.trigger();
expect(second.el.value).toBe(true);
expect(second.el.brightness).toBeCloseTo(0.75, 2);
});
});
// ─── Pushbutton ──────────────────────────────────────────────────────────────
describe('Pushbutton — attachEvents', () => {

View File

@ -242,6 +242,17 @@ export const EditorToolbar = ({
return () => window.removeEventListener('velxio-open-library-manager', open);
}, []);
// Surface a runtime circuit fault (e.g. an LED that burnt out from
// overcurrent during the live SPICE solve) as an inline message.
useEffect(() => {
const onFault = (e: Event) => {
const detail = (e as CustomEvent).detail as { message?: string } | undefined;
if (detail?.message) setMessage({ type: 'error', text: detail.message });
};
window.addEventListener('velxio-circuit-fault', onFault);
return () => window.removeEventListener('velxio-circuit-fault', onFault);
}, []);
useEffect(() => {
if (!moreMenuOpen) return;
const onClickOutside = (e: MouseEvent) => {

View File

@ -150,6 +150,51 @@ PartSimulationRegistry.register('dip-switch-8', {
* connected to GND (or a LOW GPIO). If the cathode is not wired at all the
* LED stays off regardless of the anode state.
*/
// A real 5mm indicator LED survives ~20 mA (datasheet absolute max ~30 mA).
// A sustained forward current well above that destroys it within moments —
// the classic "LED straight across a 9V battery with no series resistor"
// mistake. A professional simulator must model that, not glow happily. Once
// the solved forward current crosses LED_BURNOUT_A the LED burns out (goes
// dark and stays dark for the rest of the run) and a fault message is shown.
//
// The burnout threshold (100 mA) sits well above both the 20 mA rating AND the
// ~100-150 mA a high-power / RGB channel may legitimately draw, so a merely
// bright LED is never falsely destroyed — only a missing or grossly-undersized
// series resistor trips it. The pre-flight circuitVerifier already warns at the
// 20 mA datasheet limit BEFORE the run starts (the primary, professional
// check); this runtime burnout is the last-resort net for users who click
// "Run Anyway" past that warning, or for faults that only appear mid-run.
const LED_RATED_MAX_A = 0.02;
const LED_BURNOUT_A = 0.1;
/** Surface a circuit fault for an LED — console + a UI event the toolbar shows. */
function reportLedFault(componentId: string, kind: string, message: string): void {
console.warn(`[led] ${componentId}: ${message}`);
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
try {
window.dispatchEvent(
new CustomEvent('velxio-circuit-fault', {
detail: { componentId, kind, message },
}),
);
} catch {
/* CustomEvent unavailable (test env) — the console.warn is enough */
}
}
}
function reportLedBurnout(componentId: string, current: number): void {
const mA = current * 1000;
const amount = mA >= 1000 ? `${(mA / 1000).toFixed(1)} A` : `${mA.toFixed(0)} mA`;
reportLedFault(
componentId,
'led-burnout',
`LED burnt out — it drew ${amount}, far above its ~20 mA limit. ` +
`Add a series resistor between the supply and the LED.`,
);
}
PartSimulationRegistry.register('led', {
attachEvents: (element, simulator, getArduinoPinHelper, componentId, getPinResolver) => {
const pinManager = (simulator as any).pinManager;
@ -177,9 +222,19 @@ PartSimulationRegistry.register('led', {
// dies for good (useful diagnostic).
let lastSpiceBrightness = 0;
let lastSpiceTs = 0;
// Latches once the LED is destroyed by overcurrent; reset only when the
// part re-attaches (a fresh Run / reset re-arms it).
let burnt = false;
const HOLD_MS = 500;
const update = () => {
// A burnt-out LED stays dark for the rest of the run, no matter what
// the solver reports next.
if (burnt) {
el.value = false;
el.brightness = 0;
return;
}
// SPICE is always active. Use real branch current for analog
// brightness (0..1). The SPICE mapper emits a V-sense zero-volt
// source in series with the diode (`V_<componentId>_sense`) so
@ -207,18 +262,40 @@ PartSimulationRegistry.register('led', {
raw = sum / samples.length;
}
}
// 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.
// A non-finite branch current (NaN / Infinity) that ngspice actually
// returned is NOT "no data" — it means the solver could not find a
// stable operating point for this LED. In practice that is the textbook
// degenerate circuit: a forward-biased diode with no series resistor (a
// near-short across the supply). Burn the LED out rather than silently
// glowing via the digital fallback (the old behaviour, which let the
// "missing 220Ω" mistake light up as if it were fine). `raw === undefined`
// is different — that is the engine warming up, handled by the HOLD /
// digital-fallback path below.
if (raw !== undefined && !Number.isFinite(raw)) {
burnt = true;
el.value = false;
el.brightness = 0;
reportLedFault(
componentId,
'led-burnout',
`LED destroyed — the circuit has no stable solution (the solver returned ` +
`an undefined current). This almost always means the LED has no series ` +
`resistor. Add a resistor between the supply and the LED.`,
);
return;
}
if (raw !== undefined && Number.isFinite(raw)) {
const current = Math.abs(raw);
lastSpiceBrightness = Math.min(1, current / 0.02);
// Destructive overcurrent → burn the LED out (and tell the user why).
// Latches; the top-of-update guard keeps it dark from here on.
if (current > LED_BURNOUT_A) {
burnt = true;
el.value = false;
el.brightness = 0;
reportLedBurnout(componentId, current);
return;
}
lastSpiceBrightness = Math.min(1, current / LED_RATED_MAX_A);
lastSpiceTs = Date.now();
el.value = current > 1e-6;
el.brightness = lastSpiceBrightness;

View File

@ -112,18 +112,52 @@ export async function runNetlist(netlist: string): Promise<SpiceResult> {
await adapter.init();
await adapter.loadCircuit(netlist);
const analysis = detectAnalysis(netlist);
// Every voltage source `V_<id>` exposes its branch current as the
// ngspice vector `v_<id>#branch` (legacy form `i(v_<id>)`). These are
// the currents the circuit verifier relies on (short-circuit, LED
// overcurrent, …). We request them EXPLICITLY by name rather than
// depending on `readAllCurrentVectors`/`ngSpice_AllVecs` enumeration:
// the production Web-Worker WASM build does NOT surface source `#branch`
// vectors through `AllVecs` for an `.op` plot, so an enumeration-only
// read leaves every branch current missing in prod (the live solver
// works around this the same way — see CircuitSimulationService /
// MixedModeScheduler.setExtraVectorsOfInterest). Node test builds DO
// enumerate them, which is why this gap was invisible to the suite.
const branchVectorsOfInterest = Array.from(
new Set(
Array.from(netlist.matchAll(/^[ \t]*(V\S+)/gim)).map(
(m) => `i(${m[1]!.toLowerCase()})`,
),
),
);
// Single solve — populate the plot, then enumerate + read every
// vector via `readAllCurrentVectors` so the pointers stay valid.
// (Re-running the analysis to read vectors would create a new
// plot and invalidate everything.)
await adapter.solve(analysis, { vectorsOfInterest: [] });
const solved = await adapter.solve(analysis, {
vectorsOfInterest: branchVectorsOfInterest,
});
const all = await (adapter as unknown as AdapterWithRead).readAllCurrentVectors();
// Merge: the enumeration is the base (node voltages, time axis, …) and
// the explicit branch reads are layered on top so source/LED currents
// are present even when `AllVecs` omits them. `solved.vectors` keys are
// the requested legacy names (`i(v_x)`); normalise to the ngspice raw
// key (`v_x#branch`) so `legacyNameFor`/`getVec` resolve consistently.
const mergedVectors = new Map(all.vectors);
for (const [k, v] of solved.vectors) {
const ngKey = ngspiceNameFor(k);
if (!mergedVectors.has(ngKey)) mergedVectors.set(ngKey, v);
}
const result = {
analysis,
vectors: all.vectors,
vectors: mergedVectors,
timeAxis:
analysis.kind === 'tran'
? all.vectors.get('time')?.real ?? new Float64Array(0)
? mergedVectors.get('time')?.real ?? new Float64Array(0)
: new Float64Array(0),
solveMs: 0,
warnings: [] as string[],

View File

@ -28,6 +28,7 @@ import type { BuildNetlistInput, ElectricalSolveResult } from '../spice/types';
export type WarningSeverity = 'error' | 'warning';
export type WarningCode =
| 'solver-failed'
| 'unstable-solve'
| 'short-circuit'
| 'source-overload'
| 'led-overcurrent'
@ -85,6 +86,14 @@ export async function verifyCircuit(
const errors: CircuitWarning[] = [];
const warnings: CircuitWarning[] = [];
// Branch-current vectors that the solver returned as NaN / Infinity.
// A non-finite branch current is not "no current" — it means ngspice
// could not find a stable operating point for that source (the classic
// case: a forward-biased LED with no series resistor, or a dead short).
// We must NOT silently treat these as 0 A; they get surfaced as a
// blocking "cannot emulate" fault below.
const nonFiniteBranches = new Set<string>();
// Run a forced .op solve so currents are scalar and deterministic.
const opInput: BuildNetlistInput = { ...input, analysis: { kind: 'op' } };
const { netlist } = buildNetlist(opInput);
@ -101,7 +110,9 @@ export async function verifyCircuit(
if (Number.isFinite(v)) nodeVoltages[name.slice(2, -1)] = v;
} else if (name.startsWith('i(')) {
const v = cooked.dcValue(name);
if (Number.isFinite(v)) branchCurrents[name.slice(2, -1)] = v;
const key = name.slice(2, -1);
if (Number.isFinite(v)) branchCurrents[key] = v;
else nonFiniteBranches.add(key);
}
}
solve = {
@ -141,6 +152,18 @@ export async function verifyCircuit(
/^(battery|signal-generator|power-supply)/.test(c.metadataId),
);
for (const src of sourceComponents) {
// A non-finite source current means ngspice could not find a stable
// operating point — treat it as a blocking "cannot emulate" fault
// rather than waving the circuit through as 0 A.
if (nonFiniteBranches.has(`v_${src.id}`)) {
errors.push({
severity: 'error',
code: 'unstable-solve',
componentId: src.id,
message: `Could not solve a stable current for ${src.metadataId} ${src.id} — the circuit has no stable operating point. This usually means a short circuit, or a part driven with no current limit (for example an LED with no series resistor). Check the wiring or add a series resistor.`,
});
continue;
}
const i = Math.abs(branchCurrents[`v_${src.id}`] ?? 0);
const perInstanceLimit =
src.metadataId === 'power-supply'
@ -168,6 +191,15 @@ export async function verifyCircuit(
// that source is the LED forward current.
const leds = input.components.filter((c) => c.metadataId === 'led');
for (const led of leds) {
if (nonFiniteBranches.has(`v_${led.id}_sense`)) {
errors.push({
severity: 'error',
code: 'unstable-solve',
componentId: led.id,
message: `LED ${led.id} could not be solved — its forward current has no stable value. This almost always means the LED is wired with no series resistor (a near-short across the supply). Add a series resistor between the supply and the LED.`,
});
continue;
}
const i = Math.abs(branchCurrents[`v_${led.id}_sense`] ?? 0);
if (i > config.ledMaxAmps) {
errors.push({

View File

@ -1914,8 +1914,14 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
b.id === boardId ? { ...b, running: false, serialOutput: '', serialBaudRate: 0 } : b,
);
const isActive = s.activeBoardId === boardId;
// Bump hexEpoch so every component part re-attaches with a fresh
// closure. Without this, latched per-part state (e.g. an LED's
// `burnt` flag after overcurrent) would survive a Reset and the
// part would stay dead even after the user fixes the circuit —
// only a recompile would clear it. Mirrors restartParts().
return {
boards,
hexEpoch: s.hexEpoch + 1,
...(isActive ? { running: false, serialOutput: '', serialBaudRate: 0 } : {}),
};
});