diff --git a/frontend/src/__tests__/circuit-verifier-p1-audit.test.ts b/frontend/src/__tests__/circuit-verifier-p1-audit.test.ts new file mode 100644 index 00000000..9df34617 --- /dev/null +++ b/frontend/src/__tests__/circuit-verifier-p1-audit.test.ts @@ -0,0 +1,299 @@ +/** + * circuitVerifier — audit rules (2026-07): unpowered nets, missing return + * path, relay coil voltage mismatch. + * + * Each case reproduces a REAL circuit shape the verifier previously blessed + * with zero findings: + * (a) a battery with only its + pole wired (floating battery), + * (b) a MOSFET switching a load on a rail nothing powers, + * (c) a 12 V-coil relay fed from a 5 V supply, + * (d) a "power net" (VCC pins tied together) with no source behind it. + * + * All three rules are graph-based and run BEFORE the solve, so the + * assertions hold even when ngspice cannot converge on the broken circuit. + */ +import { describe, it, expect } from 'vitest'; +import { verifyCircuit } from '../simulation/verify/circuitVerifier'; +import type { BuildNetlistInput } from '../simulation/spice/types'; + +// ── Building blocks (same shapes as circuit-verifier.test.ts) ───────────── + +function pwr(id = 'src', volts = 5): BuildNetlistInput['components'][number] { + return { + id, + metadataId: 'signal-generator', + properties: { waveform: 'dc', offset: volts, amplitude: 0, frequency: 1 }, + }; +} + +function psu(id = 'psu', volts = 5): BuildNetlistInput['components'][number] { + return { id, metadataId: 'power-supply', properties: { voltage: volts, currentLimit: 1 } }; +} + +function battery(id = 'bat'): BuildNetlistInput['components'][number] { + return { id, metadataId: 'battery-9v', properties: {} }; +} + +function res(id: string, ohms: string): BuildNetlistInput['components'][number] { + return { id, metadataId: 'resistor', properties: { value: ohms } }; +} + +function led(id: string, color = 'red'): BuildNetlistInput['components'][number] { + return { id, metadataId: 'led', properties: { color } }; +} + +function relay(id: string, coilVolts: number): BuildNetlistInput['components'][number] { + return { id, metadataId: 'relay', properties: { coil_voltage: coilVolts } }; +} + +function w( + id: string, + from: [string, string], + to: [string, string], +): BuildNetlistInput['wires'][number] { + return { + id, + start: { componentId: from[0], pinName: from[1] }, + end: { componentId: to[0], pinName: to[1] }, + }; +} + +function input( + components: BuildNetlistInput['components'], + wires: BuildNetlistInput['wires'], + boards: BuildNetlistInput['boards'] = [], +): BuildNetlistInput { + return { components, wires, boards, analysis: { kind: 'op' } }; +} + +const codesOf = (r: { warnings: { code: string }[] }) => r.warnings.map((x) => x.code); + +// ── (a) Floating battery — no return path ───────────────────────────────── + +describe('no-return-path — floating / open-loop sources', () => { + it('warns when a battery has only its + pole wired', { timeout: 30_000 }, async () => { + const result = await verifyCircuit( + input( + [battery('bat1'), res('r1', '220'), led('led1')], + [ + w('w1', ['bat1', '+'], ['r1', '1']), + w('w2', ['r1', '2'], ['led1', 'A']), + // led1 cathode and bat1 − both left floating — the audited shape. + ], + ), + ); + const finding = result.warnings.find( + (x) => x.code === 'no-return-path' && x.componentId === 'bat1', + ); + expect(finding, JSON.stringify(result.warnings)).toBeDefined(); + expect(finding!.message).toContain('positive (+)'); + }); + + it('warns when both poles are wired but the loop never closes', { timeout: 30_000 }, async () => { + const result = await verifyCircuit( + input( + [battery('bat1'), res('r1', '220'), led('led1'), res('r2', '1k')], + [ + w('w1', ['bat1', '+'], ['r1', '1']), + w('w2', ['r1', '2'], ['led1', 'A']), + // + side dead-ends at the LED; − side dead-ends at r2. + w('w3', ['bat1', '−'], ['r2', '1']), + ], + ), + ); + const finding = result.warnings.find( + (x) => x.code === 'no-return-path' && x.componentId === 'bat1', + ); + expect(finding, JSON.stringify(result.warnings)).toBeDefined(); + expect(finding!.message).toContain('no path back'); + }); + + it('stays silent on a properly closed battery loop', { timeout: 30_000 }, async () => { + const result = await verifyCircuit( + input( + [battery('bat1'), res('r1', '470'), led('led1')], + [ + w('w1', ['bat1', '+'], ['r1', '1']), + w('w2', ['r1', '2'], ['led1', 'A']), + w('w3', ['led1', 'C'], ['bat1', '−']), + ], + ), + ); + expect(codesOf(result), JSON.stringify(result.warnings)).not.toContain('no-return-path'); + expect(codesOf(result)).not.toContain('unpowered-net'); + }); + + it('a load returning through the shared ground of a signal generator closes the loop', { timeout: 30_000 }, async () => { + const result = await verifyCircuit( + input( + [pwr('src', 5), res('r1', '220'), led('led1')], + [ + w('w1', ['src', 'SIG'], ['r1', '1']), + w('w2', ['r1', '2'], ['led1', 'A']), + w('w3', ['led1', 'C'], ['src', 'GND']), + ], + ), + ); + expect(codesOf(result), JSON.stringify(result.warnings)).not.toContain('no-return-path'); + expect(codesOf(result)).not.toContain('unpowered-net'); + }); +}); + +// ── (b) MOSFET switching a dead rail — unpowered net ────────────────────── + +describe('unpowered-net — loads on a rail no source reaches', () => { + it('warns for a load switched by a MOSFET whose high side is a dead rail', { timeout: 30_000 }, async () => { + // The "otra cosa" branch (SIG → r1 → led1 → GND) works; the switched + // branch hangs off a net nothing powers. The MOSFET gate is driven, its + // source is grounded — previously this solved quietly to 0 A everywhere. + const result = await verifyCircuit( + input( + [ + pwr('src', 5), + res('r1', '220'), + led('led1'), + { id: 'm1', metadataId: 'mosfet-irf540', properties: {} }, + res('rload', '100'), + led('led2'), + ], + [ + w('w1', ['src', 'SIG'], ['r1', '1']), + w('w2', ['r1', '2'], ['led1', 'A']), + w('w3', ['led1', 'C'], ['src', 'GND']), + w('w4', ['m1', 'G'], ['src', 'SIG']), + w('w5', ['m1', 'S'], ['src', 'GND']), + w('w6', ['m1', 'D'], ['rload', '2']), + w('w7', ['rload', '1'], ['led2', 'A']), // the "12V rail" — no source on it + w('w8', ['led2', 'C'], ['m1', 'D']), + ], + ), + ); + const finding = result.warnings.find((x) => x.code === 'unpowered-net'); + expect(finding, JSON.stringify(result.warnings)).toBeDefined(); + expect(finding!.message).toContain('No power source reaches'); + }); + + it('warns when VCC pins are tied together with no source behind them (phantom rail)', { timeout: 30_000 }, async () => { + // A healthy battery branch elsewhere, plus two modules whose VCC pins + // feed each other — the audited "power net with no source". + const result = await verifyCircuit( + input( + [ + battery('bat1'), + res('r1', '470'), + led('led1'), + { id: 'dht', metadataId: 'dht22', properties: {} }, + { id: 'imu', metadataId: 'mpu6050', properties: {} }, + ], + [ + w('w1', ['bat1', '+'], ['r1', '1']), + w('w2', ['r1', '2'], ['led1', 'A']), + w('w3', ['led1', 'C'], ['bat1', '−']), + w('w4', ['dht', 'VCC'], ['imu', 'VCC']), + w('w5', ['dht', 'GND'], ['bat1', '−']), + w('w6', ['imu', 'GND'], ['bat1', '−']), + ], + ), + ); + const unpowered = result.warnings.filter((x) => x.code === 'unpowered-net'); + expect(unpowered.length, JSON.stringify(result.warnings)).toBeGreaterThan(0); + expect( + unpowered.some((x) => x.componentId === 'dht' || x.componentId === 'imu'), + JSON.stringify(unpowered), + ).toBe(true); + }); + + it('stays silent when the module VCC actually reaches the battery', { timeout: 30_000 }, async () => { + const result = await verifyCircuit( + input( + [battery('bat1'), { id: 'dht', metadataId: 'dht22', properties: {} }], + [ + w('w1', ['bat1', '+'], ['dht', 'VCC']), + w('w2', ['dht', 'GND'], ['bat1', '−']), + ], + ), + ); + expect(codesOf(result), JSON.stringify(result.warnings)).not.toContain('unpowered-net'); + }); +}); + +// ── (c) Relay coil voltage vs supply ────────────────────────────────────── + +describe('voltage-mismatch — relay coil vs the rail that feeds it', () => { + it('warns when a 12 V coil is fed from a 5 V supply', { timeout: 30_000 }, async () => { + const result = await verifyCircuit( + input( + [psu('psu1', 5), relay('k1', 12), res('rl', '100')], + [ + w('w1', ['psu1', '+'], ['k1', 'COIL+']), + w('w2', ['k1', 'COIL-'], ['psu1', '-']), + w('w3', ['psu1', '+'], ['k1', 'COM']), + w('w4', ['k1', 'NO'], ['rl', '1']), + w('w5', ['rl', '2'], ['psu1', '-']), + ], + ), + ); + const finding = result.warnings.find( + (x) => x.code === 'voltage-mismatch' && x.componentId === 'k1', + ); + expect(finding, JSON.stringify(result.warnings)).toBeDefined(); + // Both values must be visible to the user. + expect(finding!.message).toContain('12.0 V'); + expect(finding!.message).toContain('5.0 V'); + expect(finding!.metric).toBe(5); + }); + + it('warns when a 5 V coil is overdriven from 12 V', { timeout: 30_000 }, async () => { + const result = await verifyCircuit( + input( + [psu('psu1', 12), relay('k1', 5)], + [ + w('w1', ['psu1', '+'], ['k1', 'COIL+']), + w('w2', ['k1', 'COIL-'], ['psu1', '-']), + w('w3', ['psu1', '+'], ['k1', 'COM']), + ], + ), + ); + const finding = result.warnings.find( + (x) => x.code === 'voltage-mismatch' && x.componentId === 'k1', + ); + expect(finding, JSON.stringify(result.warnings)).toBeDefined(); + expect(finding!.message).toContain('overheat'); + }); + + it('stays silent when coil and supply match', { timeout: 30_000 }, async () => { + const result = await verifyCircuit( + input( + [psu('psu1', 5), relay('k1', 5)], + [ + w('w1', ['psu1', '+'], ['k1', 'COIL+']), + w('w2', ['k1', 'COIL-'], ['psu1', '-']), + w('w3', ['psu1', '+'], ['k1', 'COM']), + ], + ), + ); + expect(codesOf(result), JSON.stringify(result.warnings)).not.toContain('voltage-mismatch'); + }); + + it('finds the supply through a series switch (region fallback)', { timeout: 30_000 }, async () => { + // Coil+ reaches the 5 V supply only through a pushbutton, so no supply + // sits DIRECTLY on the coil nets — the rule falls back to the coil's + // power region to discover the nominal voltage. + const result = await verifyCircuit( + input( + [psu('psu1', 5), relay('k1', 12), { id: 'btn', metadataId: 'pushbutton', properties: {} }], + [ + w('w1', ['psu1', '+'], ['btn', '1.l']), + w('w2', ['btn', '2.r'], ['k1', 'COIL+']), + w('w3', ['k1', 'COIL-'], ['psu1', '-']), + w('w4', ['psu1', '+'], ['k1', 'COM']), + ], + ), + ); + const finding = result.warnings.find( + (x) => x.code === 'voltage-mismatch' && x.componentId === 'k1', + ); + expect(finding, JSON.stringify(result.warnings)).toBeDefined(); + expect(finding!.metric).toBe(5); + }); +}); diff --git a/frontend/src/__tests__/circuit-verifier.test.ts b/frontend/src/__tests__/circuit-verifier.test.ts index a453edb6..20395d51 100644 --- a/frontend/src/__tests__/circuit-verifier.test.ts +++ b/frontend/src/__tests__/circuit-verifier.test.ts @@ -487,6 +487,20 @@ function toInput(ex: { components: any[]; wires: any[] }): BuildNetlistInput { }; } +// The audit rules (2026-07) are warnings, so the errors-only assertion would +// not catch a false positive — assert explicitly that no shipping example +// trips them. +const AUDIT_WARNING_CODES = new Set(['unpowered-net', 'no-return-path', 'voltage-mismatch']); + +function galleryFindings(result: Awaited>): string[] { + return [ + ...result.errors.map((e) => `${e.code}(${e.componentId ?? '-'})`), + ...result.warnings + .filter((w) => AUDIT_WARNING_CODES.has(w.code)) + .map((w) => `${w.code}(${w.componentId ?? '-'})`), + ]; +} + describe('verifyCircuit — shipping gallery examples are clean', () => { it( 'every digital example passes pre-flight verification', @@ -494,11 +508,9 @@ describe('verifyCircuit — shipping gallery examples are clean', () => { async () => { const failures: string[] = []; for (const ex of digitalExamples) { - const result = await verifyCircuit(toInput(ex)); - if (result.errors.length > 0) { - failures.push( - `${ex.id}: ${result.errors.map((e) => `${e.code}(${e.componentId ?? '-'})`).join(', ')}`, - ); + const findings = galleryFindings(await verifyCircuit(toInput(ex))); + if (findings.length > 0) { + failures.push(`${ex.id}: ${findings.join(', ')}`); } } expect(failures, failures.join('\n')).toEqual([]); @@ -511,11 +523,9 @@ describe('verifyCircuit — shipping gallery examples are clean', () => { async () => { const failures: string[] = []; for (const ex of analogExamples) { - const result = await verifyCircuit(toInput(ex)); - if (result.errors.length > 0) { - failures.push( - `${ex.id}: ${result.errors.map((e) => `${e.code}(${e.componentId ?? '-'})`).join(', ')}`, - ); + const findings = galleryFindings(await verifyCircuit(toInput(ex))); + if (findings.length > 0) { + failures.push(`${ex.id}: ${findings.join(', ')}`); } } expect(failures, failures.join('\n')).toEqual([]); diff --git a/frontend/src/simulation/verify/circuitVerifier.ts b/frontend/src/simulation/verify/circuitVerifier.ts index 6518f970..1dc333c3 100644 --- a/frontend/src/simulation/verify/circuitVerifier.ts +++ b/frontend/src/simulation/verify/circuitVerifier.ts @@ -23,6 +23,9 @@ */ import { buildNetlist } from '../spice/NetlistBuilder'; import { runNetlist as runSpice } from '../spice/runNetlist'; +import { UnionFind } from '../spice/unionFind'; +import { BOARD_PIN_GROUPS } from '../spice/boardPinGroups'; +import { isBreadboard } from '../../utils/breadboardNets'; import type { BuildNetlistInput, ElectricalSolveResult } from '../spice/types'; import { COMPONENT_RATINGS } from './componentRatings'; @@ -39,7 +42,10 @@ export type WarningCode = | 'power-short' | 'shorted-component' | 'resistor-overpower' - | 'led-no-current'; + | 'led-no-current' + | 'unpowered-net' + | 'no-return-path' + | 'voltage-mismatch'; export interface CircuitWarning { severity: WarningSeverity; @@ -252,6 +258,355 @@ export async function verifyCircuit( } } + // ── Audit rules (2026-07): unpowered nets / missing return path / relay + // coil voltage mismatch ──────────────────────────────────────────────── + // Graph-based, no solve — they fire even on circuits ngspice cannot solve. + // All three are non-blocking warnings: the goal is that the user (and the + // agent's pre-flight) SEE the fault, not a hard stop. Motivated by real + // audited circuits the verifier previously blessed with zero findings: + // a battery with only one pole wired, a MOSFET switching a rail nothing + // powers, a 12 V-coil relay fed from 5 V, and "power" nets with no source. + { + const boardIds = new Set(input.boards.map((b) => b.id)); + const compById = new Map(input.components.map((c) => [c.id, c] as const)); + // Board kinds double as component metadataIds in some flows (gallery + // sweeps, tests pass boards inside `components`): treat those components + // as self-powered boards. Keyed by kind → logic voltage. + const boardVccByKind = new Map( + Object.entries(BOARD_PIN_GROUPS) + .filter(([kind]) => kind !== 'default') + .map(([kind, group]) => [kind, group.vcc] as const), + ); + // Canvas element type of the esp32 board — appears as a component + // metadataId in the gallery flows. + if (!boardVccByKind.has('esp32-devkit-v1')) { + boardVccByKind.set('esp32-devkit-v1', boardVccByKind.get('esp32') ?? 3.3); + } + // Programmable custom chips are active, self-powered parts (their pins + // drive) — never report them or their nets as unpowered. + const isActiveChip = (metadataId: string): boolean => metadataId === 'custom-chip'; + const dcSourceIds = new Set( + input.components.filter((c) => sourceInfo(c) !== null).map((c) => c.id), + ); + + const netOf = (entityId: string, pinName: string): string | undefined => + pinNetMap.get(`${entityId}:${pinName}`) ?? + (pinName === '−' + ? pinNetMap.get(`${entityId}:-`) + : pinName === '-' + ? pinNetMap.get(`${entityId}:−`) + : undefined); + + // Return-side nets: the canonical ground plus every net a discrete + // source's negative terminal sits on. Everything in a circuit rides its + // return net, so treating it as a conductor would merge unrelated + // sub-circuits and mask dead branches — for POWER reachability these are + // barriers, for RETURN reachability they are the highway. + const NEG_PIN_NAMES = ['−', '-', 'GND']; + const returnNets = new Set(['0']); + for (const src of input.components) { + if (!dcSourceIds.has(src.id)) continue; + for (const pinName of wiredPins.get(src.id) ?? []) { + if (!NEG_PIN_NAMES.includes(pinName)) continue; + const net = netOf(src.id, pinName); + if (net) returnNets.add(net); + } + } + + // Which pins of a part conduct to each other internally. Most parts join + // all their wired pins (over-approximating conduction keeps false + // positives down); transistors only conduct through their channel (a + // MOSFET gate does not power its drain), and a relay's coil is + // galvanically isolated from its contacts. + const conductionGroups = (metadataId: string, pins: string[]): string[][] => { + if (/^mosfet-/.test(metadataId)) return [['D', 'S']]; + if (/^bjt-/.test(metadataId)) return [['C', 'E']]; + if (metadataId === 'relay') return [['COIL+', 'COIL-'], ['COM', 'NO', 'NC']]; + return [pins]; + }; + + // Region analysis: nets are nodes, entities join the nets their pins sit + // on. `power: true` asks "which nets can a power source actually reach?" + // (return nets don't bridge, sources don't conduct internally); + // `power: false` asks "are these nets connected at all?" (everything + // conducts — used for the return-path check). + const buildRegions = (opts: { excludeId?: string; power: boolean }): UnionFind => { + const uf2 = new UnionFind(); + const usable = (net: string | undefined): net is string => + net !== undefined && !(opts.power && returnNets.has(net)); + for (const [entityId, pins] of wiredPins) { + if (entityId === opts.excludeId) continue; + if (opts.power && dcSourceIds.has(entityId)) continue; + const comp = compById.get(entityId); + // Breadboard internal connectivity is already folded into the nets. + if (comp && isBreadboard(comp.metadataId)) continue; + // Channel-only conduction applies to POWER reachability (a MOSFET + // gate cannot power the drain rail). For RETURN reachability every + // pin joins: voltage-driven inputs (BJT base, MOSFET gate) are + // legitimate signal sinks and must not read as broken loops. + const groups = comp && opts.power + ? conductionGroups(comp.metadataId, [...pins]) + : [[...pins]]; // boards and unknown entities conduct across all pins + for (const group of groups) { + let anchor: string | undefined; + for (const pinName of group) { + const net = netOf(entityId, pinName); + if (!usable(net)) continue; + uf2.add(net); + if (anchor === undefined) anchor = net; + else uf2.union(anchor, net); + } + } + } + // Wires join their endpoint nets (only length-modelled wires actually + // split endpoints into two nets; for the rest this is a no-op union). + for (const wire of input.wires) { + const a = netOf(wire.start.componentId, wire.start.pinName); + const b = netOf(wire.end.componentId, wire.end.pinName); + if (usable(a) && usable(b)) { + uf2.add(a); + uf2.add(b); + uf2.union(a, b); + } + } + return uf2; + }; + + // Nets that genuinely inject power: discrete sources' positive terminals, + // every non-ground board pin (GPIOs drive, supply pins supply), and the + // shared VCC rail — but the rail only when a board actually defines it. + // VCC-named component pins wired together WITHOUT any board form a + // phantom rail that nothing powers (the audited "power net, no source"). + const positiveSourceNets = new Set(); + const addEntitySourceNets = (entityId: string) => { + for (const pinName of wiredPins.get(entityId) ?? []) { + const net = netOf(entityId, pinName); + if (net && !returnNets.has(net)) positiveSourceNets.add(net); + } + }; + for (const b of input.boards) addEntitySourceNets(b.id); + for (const entityId of wiredPins.keys()) { + // Endpoints belonging to no known component/board: be conservative and + // treat them as power-capable rather than invent findings about them. + if (!compById.has(entityId) && !boardIds.has(entityId)) addEntitySourceNets(entityId); + } + for (const c of input.components) { + if (boardVccByKind.has(c.metadataId) || isActiveChip(c.metadataId)) { + addEntitySourceNets(c.id); + } + const info = sourceInfo(c); + if (!info) continue; + for (const pinName of wiredPins.get(c.id) ?? []) { + if (!info.posPins.includes(pinName)) continue; + const net = netOf(c.id, pinName); + if (net && !returnNets.has(net)) positiveSourceNets.add(net); + } + } + const railDriven = + input.boards.length > 0 || input.components.some((c) => boardVccByKind.has(c.metadataId)); + if (railDriven) positiveSourceNets.add('vcc_rail'); + + const skipForAudit = (c: BuildNetlistInput['components'][number]): boolean => + dcSourceIds.has(c.id) || + boardVccByKind.has(c.metadataId) || + isActiveChip(c.metadataId) || + isBreadboard(c.metadataId) || + c.metadataId.startsWith('instr-'); + + const powerRegions = buildRegions({ power: true }); + const poweredRoots = new Set(); + for (const net of positiveSourceNets) poweredRoots.add(powerRegions.find(net)); + + // ── Rule A1: a power-input pin on a net no source reaches ───────────── + // The part's own body must not bridge power onto its supply pin (a + // sensor's VCC is not powered by its SDA), so each part is checked + // against a region map built WITHOUT itself. + const POWER_INPUT_PIN_RE = /^(vcc\d*|vdd|vin|v\+|avcc|coil\+)$/i; + const flaggedUnpowered = new Set(); + for (const comp of input.components) { + if (skipForAudit(comp)) continue; + const rated = new Set(COMPONENT_RATINGS[comp.metadataId]?.supplyPins.map((p) => p.name) ?? []); + const pins = wiredPins.get(comp.id); + if (!pins) continue; + for (const pinName of pins) { + if (!rated.has(pinName) && !POWER_INPUT_PIN_RE.test(pinName)) continue; + const net = netOf(comp.id, pinName); + if (!net || returnNets.has(net)) continue; + const solo = buildRegions({ excludeId: comp.id, power: true }); + const root = solo.find(net); + if ([...positiveSourceNets].some((s) => solo.find(s) === root)) continue; + flaggedUnpowered.add(comp.id); + warnings.push({ + severity: 'warning', + code: 'unpowered-net', + componentId: comp.id, + message: `${comp.metadataId} ${comp.id} has its ${pinName} pin on a net with no power source — no battery, power supply, or board supply pin reaches that net, so the part stays unpowered. Wire the net to a real supply.`, + }); + break; // one unpowered warning per part + } + } + + // ── Rule A2: whole sub-circuits no power source reaches ─────────────── + // Judged per conduction group so a relay whose contacts are fine still + // reports its dead coil. One warning per stranded region, naming the + // parts on it. + const strandedByRoot = new Map(); + for (const comp of input.components) { + if (flaggedUnpowered.has(comp.id) || skipForAudit(comp)) continue; + const pins = wiredPins.get(comp.id); + if (!pins) continue; + for (const group of conductionGroups(comp.metadataId, [...pins])) { + const roots = new Set(); + for (const pinName of group) { + const net = netOf(comp.id, pinName); + if (!net || returnNets.has(net)) continue; // ground pins don't count + roots.add(powerRegions.find(net)); + } + if (roots.size === 0) continue; + if ([...roots].some((r) => poweredRoots.has(r))) continue; + const anchor = [...roots][0]!; + const list = strandedByRoot.get(anchor) ?? []; + if (!list.includes(comp.id)) list.push(comp.id); + strandedByRoot.set(anchor, list); + } + } + for (const ids of strandedByRoot.values()) { + const shown = ids + .slice(0, 3) + .map((id) => `${compById.get(id)?.metadataId ?? 'component'} ${id}`) + .join(', '); + const suffix = ids.length > 3 ? ` (+${ids.length - 3} more)` : ''; + warnings.push({ + severity: 'warning', + code: 'unpowered-net', + componentId: ids[0], + message: `No power source reaches ${shown}${suffix} — that part of the circuit never connects to a battery, power supply, or board pin, so no current can flow through it.`, + }); + } + + // ── Rule B: source without a return path ────────────────────────────── + // Current needs a closed loop. A battery with a single pole wired, or a + // source whose + side never reconnects to its − side, drives nothing — + // the audited "floating battery" circuit ran with zero findings. + for (const src of input.components) { + const info = sourceInfo(src); + if (!info) continue; + const set = wiredPins.get(src.id); + if (!set || set.size === 0) continue; // fully unwired — not a mistake yet + const posPin = [...set].find((p) => info.posPins.includes(p)); + const negPin = [...set].find((p) => NEG_PIN_NAMES.includes(p)); + if (!posPin !== !negPin) { + const wired = posPin ? 'positive (+)' : 'negative (−)'; + const missing = posPin ? 'negative (−)' : 'positive (+)'; + warnings.push({ + severity: 'warning', + code: 'no-return-path', + componentId: src.id, + message: `${src.metadataId} ${src.id} has only its ${wired} terminal wired. Current needs a closed loop back into the ${missing} terminal — with it floating, no current can flow anywhere in this circuit.`, + }); + continue; + } + if (!posPin || !negPin) continue; + const posNet = netOf(src.id, posPin); + const negNet = netOf(src.id, negPin); + if (!posNet || !negNet || posNet === negNet) continue; // short: other rules + const returnRegions = buildRegions({ excludeId: src.id, power: false }); + if (returnRegions.find(posNet) !== returnRegions.find(negNet)) { + warnings.push({ + severity: 'warning', + code: 'no-return-path', + componentId: src.id, + message: `Current leaving ${src.metadataId} ${src.id}'s ${posPin} terminal has no path back to its ${negPin} terminal — the loop never closes, so no current can flow. Check the return (GND) side of the circuit for a missing wire.`, + }); + } + } + + // ── Rule C: relay coil voltage vs the supply actually feeding it ────── + // A 12 V-coil relay on a 5 V rail sits far below its ~60% pull-in + // threshold and never actuates (audited case); the reverse overdrives + // and burns the coil. Compare coil_voltage against the nominal voltage + // of the supply on the coil nets (falling back to the coil's power + // region for coils fed through a switch or transistor). + const dominantVcc = + input.boards[0]?.vcc ?? + (() => { + const bk = input.components.find((c) => boardVccByKind.has(c.metadataId)); + return bk ? boardVccByKind.get(bk.metadataId)! : 5; + })(); + const nominalOnNets = (nets: ReadonlySet): number | undefined => { + let best: number | undefined; + const consider = (v: number) => { + if (Number.isFinite(v) && v > 0 && (best === undefined || v > best)) best = v; + }; + if (nets.has('vcc_rail') && railDriven) consider(dominantVcc); + for (const c of input.components) { + const info = sourceInfo(c); + if (info) { + for (const pinName of wiredPins.get(c.id) ?? []) { + if (!info.posPins.includes(pinName)) continue; + const net = netOf(c.id, pinName); + if (net && !returnNets.has(net) && nets.has(net)) consider(info.volts); + } + } + if (boardVccByKind.has(c.metadataId)) { + for (const pinName of wiredPins.get(c.id) ?? []) { + const net = netOf(c.id, pinName); + if (net && !returnNets.has(net) && net !== 'vcc_rail' && nets.has(net)) { + consider(boardVccByKind.get(c.metadataId)!); + } + } + } + } + for (const b of input.boards) { + for (const pinName of wiredPins.get(b.id) ?? []) { + const net = netOf(b.id, pinName); + if (!net || returnNets.has(net) || !nets.has(net)) continue; + const state = b.pins[pinName]; + consider(state?.type === 'digital' ? state.v : b.vcc); + } + } + return best; + }; + for (const comp of input.components) { + if (comp.metadataId !== 'relay') continue; + const coilV = Number(comp.properties.coil_voltage ?? 5); + if (!Number.isFinite(coilV) || coilV <= 0) continue; + const coilNets = new Set(); + for (const pinName of ['COIL+', 'COIL-']) { + const net = netOf(comp.id, pinName); + if (net && !returnNets.has(net)) coilNets.add(net); + } + if (coilNets.size === 0) continue; + let supplyV = nominalOnNets(coilNets); + if (supplyV === undefined) { + const roots = new Set([...coilNets].map((n) => powerRegions.find(n))); + const reachable = new Set(); + for (const net of new Set(pinNetMap.values())) { + if (!returnNets.has(net) && roots.has(powerRegions.find(net))) reachable.add(net); + } + supplyV = nominalOnNets(reachable); + } + if (supplyV === undefined) continue; // unpowered coil — Rule A covers it + if (supplyV < coilV * 0.6) { + warnings.push({ + severity: 'warning', + code: 'voltage-mismatch', + componentId: comp.id, + metric: supplyV, + message: `Relay ${comp.id} has a ${formatVolts(coilV)} coil (coil_voltage = ${coilV}) but its coil is fed from a ${formatVolts(supplyV)} supply — below the ~60% pull-in threshold, so the relay will never energise. Use a relay with a ${formatVolts(supplyV)} coil, or feed the coil ${formatVolts(coilV)}.`, + }); + } else if (supplyV > coilV * 1.5) { + warnings.push({ + severity: 'warning', + code: 'voltage-mismatch', + componentId: comp.id, + metric: supplyV, + message: `Relay ${comp.id} has a ${formatVolts(coilV)} coil (coil_voltage = ${coilV}) but its coil is fed from ${formatVolts(supplyV)} — far above its rating. A real coil would overheat and burn out; use a ${formatVolts(supplyV)}-rated coil or the matching ${formatVolts(coilV)} supply.`, + }); + } + } + } + let solve: ElectricalSolveResult | undefined; try { const cooked = await runSpice(netlist); diff --git a/frontend/src/simulation/verify/verifyFromStore.ts b/frontend/src/simulation/verify/verifyFromStore.ts index a0f0c5d9..bb38e8c6 100644 --- a/frontend/src/simulation/verify/verifyFromStore.ts +++ b/frontend/src/simulation/verify/verifyFromStore.ts @@ -19,7 +19,10 @@ export async function verifyCircuitFromStore(): Promise c.metadataId.startsWith('signal-generator') || c.metadataId.startsWith('battery'), + (c) => + c.metadataId.startsWith('signal-generator') || + c.metadataId.startsWith('battery') || + c.metadataId.startsWith('power-supply'), ); if (!hasSource && sim.boards.length === 0) return null;