From 8683c1ecf0a048f0f931f1f46734a2d2546899a2 Mon Sep 17 00:00:00 2001 From: David Montero Date: Thu, 18 Jun 2026 02:05:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(sim):=20P2=20wiring=20ERC=20=E2=80=94=20mi?= =?UTF-8?q?ssing=20power=20+=20dangling=202-terminal=20parts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the connection ("malas conexiones") checks, graph-based and run before the solve so they report even on circuits too incomplete to solve: - Missing power: a rated peripheral (sensor/display) wired into the circuit but missing its VCC or GND connection -> warning. Boards are excluded (they live in input.boards and self-power). - Dangling 2-terminal part: a resistor / LED / capacitor / diode / inductor connected on only one side (the other terminal floating) -> warning. Both non-blocking. Verified zero false positives across all 69 gallery examples. Tests: dangling resistor warns, fully-wired doesn't, module missing GND warns. --- .../src/__tests__/circuit-verifier.test.ts | 52 +++++++++++ .../src/simulation/verify/circuitVerifier.ts | 89 +++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/frontend/src/__tests__/circuit-verifier.test.ts b/frontend/src/__tests__/circuit-verifier.test.ts index bbe65423..3877f6de 100644 --- a/frontend/src/__tests__/circuit-verifier.test.ts +++ b/frontend/src/__tests__/circuit-verifier.test.ts @@ -376,6 +376,58 @@ describe('verifyCircuit — electrolytic capacitor', () => { ); }); +describe('verifyCircuit — wiring ERC (bad connections)', () => { + it( + 'warns when a 2-terminal part is connected on only one side', + { timeout: 30_000 }, + async () => { + const input: BuildNetlistInput = { + components: [pwr('src', 5), res('r1', '1k')], + wires: [w('w1', ['src', 'SIG'], ['r1', '1'])], // r1 pin '2' left floating + boards: [], + analysis: { kind: 'op' }, + }; + const result = await verifyCircuit(input); + const mc = result.warnings.find((x) => x.code === 'missing-connection' && x.componentId === 'r1'); + expect(mc, JSON.stringify(result.warnings)).toBeDefined(); + }, + ); + + it( + 'does NOT warn when both terminals are wired', + { timeout: 30_000 }, + async () => { + const input: BuildNetlistInput = { + components: [pwr('src', 5), res('r1', '1k')], + wires: [ + w('w1', ['src', 'SIG'], ['r1', '1']), + w('w2', ['r1', '2'], ['src', 'GND']), + ], + boards: [], + analysis: { kind: 'op' }, + }; + const result = await verifyCircuit(input); + expect(result.warnings.map((x) => x.code)).not.toContain('missing-connection'); + }, + ); + + it( + 'warns when a powered module is missing its ground connection', + { timeout: 30_000 }, + async () => { + const input: BuildNetlistInput = { + components: [pwr('src', 5), { id: 'o1', metadataId: 'ssd1306', properties: {} }], + wires: [w('w1', ['src', 'SIG'], ['o1', 'VIN'])], // VIN wired, GND not + boards: [], + analysis: { kind: 'op' }, + }; + const result = await verifyCircuit(input); + const mc = result.warnings.find((x) => x.code === 'missing-connection' && x.componentId === 'o1'); + expect(mc, JSON.stringify(result.warnings)).toBeDefined(); + }, + ); +}); + // ── Sanity: shipping examples never trigger errors ───────────────────────── // If any gallery example produces a verifier error, that's a bug in the // example itself. Loop a handful of representative ones to catch diff --git a/frontend/src/simulation/verify/circuitVerifier.ts b/frontend/src/simulation/verify/circuitVerifier.ts index 7fcccaae..597caedc 100644 --- a/frontend/src/simulation/verify/circuitVerifier.ts +++ b/frontend/src/simulation/verify/circuitVerifier.ts @@ -35,6 +35,7 @@ export type WarningCode = | 'led-overcurrent' | 'over-voltage' | 'reverse-polarity' + | 'missing-connection' | 'resistor-overpower' | 'led-no-current'; @@ -143,6 +144,73 @@ export async function verifyCircuit( } } + // ── Wiring ERC (graph-based, no solve) ───────────────────────────────── + // Bad-connection checks that don't need a solve, so they run even when the + // circuit is too incomplete to solve. Build a per-component set of wired pin + // names from the wires first. + const wiredPins = new Map>(); + for (const wire of input.wires) { + for (const end of [wire.start, wire.end]) { + let s = wiredPins.get(end.componentId); + if (!s) { + s = new Set(); + wiredPins.set(end.componentId, s); + } + s.add(end.pinName); + } + } + const pinWired = (set: Set | undefined, name: string): boolean => { + if (!set) return false; + if (set.has(name)) return true; + // Tolerate ASCII '-' vs the Unicode minus on an electrolytic cap's "−" pin. + if (name === '−' && set.has('-')) return true; + if (name === '-' && set.has('−')) return true; + return false; + }; + + // Missing power: a rated peripheral wired into the circuit but missing its + // supply or ground connection won't work. (Boards live in input.boards, not + // input.components, so they're correctly excluded — a board self-powers.) + for (const comp of input.components) { + const rating = COMPONENT_RATINGS[comp.metadataId]; + if (!rating) continue; + const set = wiredPins.get(comp.id); + if (!set || set.size === 0) continue; // not connected yet — not a mistake + if (!rating.supplyPins.some((sp) => pinWired(set, sp.name))) { + warnings.push({ + severity: 'warning', + code: 'missing-connection', + componentId: comp.id, + message: `${rating.label} ${comp.id} is wired up but its power (VCC) pin isn't connected — connect it to a supply or it won't work.`, + }); + } + if (!rating.gndPins.some((g) => pinWired(set, g))) { + warnings.push({ + severity: 'warning', + code: 'missing-connection', + componentId: comp.id, + message: `${rating.label} ${comp.id} is wired up but its ground (GND) pin isn't connected — connect GND or it won't work.`, + }); + } + } + + // Dangling two-terminal part: a resistor / LED / capacitor / diode connected + // on only one side has no current path through it. + for (const comp of input.components) { + const pins = twoTerminalPins(comp.metadataId); + if (!pins) continue; + const set = wiredPins.get(comp.id); + if (!set) continue; + if (pinWired(set, pins[0]) !== pinWired(set, pins[1])) { + warnings.push({ + severity: 'warning', + code: 'missing-connection', + componentId: comp.id, + message: `${comp.metadataId} ${comp.id} is connected on only one side — its other terminal is floating, so no current can flow through it.`, + }); + } + } + let solve: ElectricalSolveResult | undefined; try { const cooked = await runSpice(netlist); @@ -420,6 +488,27 @@ function isElectrolyticCap(metadataId: string): boolean { return metadataId === 'capacitor-electrolytic' || metadataId.startsWith('cap-elec'); } +/** The two terminal pin names of a 2-terminal part, or null if not 2-terminal. */ +function twoTerminalPins(id: string): [string, string] | null { + if (id === 'capacitor-electrolytic' || id.startsWith('cap-elec')) return ['+', '−']; + if ( + id === 'resistor' || + id.startsWith('resistor-') || + id === 'capacitor' || + (id.startsWith('cap-') && !id.startsWith('cap-elec')) || + id === 'inductor' + ) { + return ['1', '2']; + } + if (id === 'analog-resistor' || id === 'analog-capacitor' || id === 'analog-inductor') { + return ['A', 'B']; + } + if (id === 'led' || id === 'diode' || id.startsWith('diode-') || id.startsWith('zener-')) { + return ['A', 'C']; + } + return null; +} + /** Parse a voltage rating like '25', '25V', '6.3' → volts (null if unparseable). */ function parseVolts(raw: unknown): number | null { if (raw === undefined || raw === null) return null;