Merge branch 'feat/circuit-safety-p1'
This commit is contained in:
commit
fc048ff941
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ReturnType<typeof verifyCircuit>>): 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([]);
|
||||
|
|
|
|||
|
|
@ -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<string, number>(
|
||||
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<string>(['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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string, string[]>();
|
||||
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<string>();
|
||||
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<string>): 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<string>();
|
||||
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<string>();
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ export async function verifyCircuitFromStore(): Promise<VerificationResult | nul
|
|||
const sim = useSimulatorStore.getState();
|
||||
// Skip if the circuit hasn't got anything analysable on it yet.
|
||||
const hasSource = sim.components.some(
|
||||
(c) => 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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue