feat(sim): P1 over-voltage warnings for parts with a rated input voltage

Adds a non-blocking circuit-verifier rule: a component whose supply pin sees
more than its datasheet absolute-maximum voltage warns ("X V on the VIN pin --
above its Y V maximum; not emulated accurately"). This is the "fed too much
voltage" mistake the operator asked for (a 3.3-5V module wired to a 9V battery).

- New componentRatings.ts: per-PIN abs-max table (SSD1306/ILI9341 displays,
  DHT/BMP280/HC-SR04/MPU6050 sensors, NeoPixel, servo). Per-pin thresholds so a
  3V3 pin (3.6V) and a VIN pin (6V) are judged separately. Unknown parts are
  simply not checked; an unwired or floating supply pin is skipped.
- circuitVerifier reads each rated part's supply-vs-ground voltage from the
  solved nets (via pinNetMap) and warns when it exceeds the rating.
- VCC/VDD/3V3/5V pins ride the shared vcc_rail net (NetlistBuilder convention);
  VIN is a normal net. Both handled.
- Tests: 9V on a module VIN warns; 5V on VIN does not; a 3.3V pin on a 5V rail
  warns.

Boards (esp32/pico/arduino) carry ratings in the table but aren't checked yet
-- BoardForSpice doesn't thread its boardKind; follow-up.
This commit is contained in:
David Montero 2026-06-17 22:40:03 +02:00
parent b7d1e6469d
commit 5a9bb3a70e
3 changed files with 229 additions and 1 deletions

View File

@ -191,6 +191,76 @@ describe('verifyCircuit — threshold overrides', () => {
);
});
describe('verifyCircuit — over-voltage on rated parts', () => {
function part(id: string, metadataId: string): BuildNetlistInput['components'][number] {
return { id, metadataId, properties: {} };
}
it(
'warns when a 3.3-5V module (SSD1306 VIN) is fed 9 V',
{ timeout: 30_000 },
async () => {
const input: BuildNetlistInput = {
components: [pwr('src', 9), part('oled1', 'ssd1306')],
wires: [
w('w1', ['src', 'SIG'], ['oled1', 'VIN']),
w('w2', ['oled1', 'GND'], ['src', 'GND']),
],
boards: [],
analysis: { kind: 'op' },
};
const result = await verifyCircuit(input);
const ov = result.warnings.find((x) => x.code === 'over-voltage');
expect(ov, JSON.stringify(result.warnings)).toBeDefined();
expect(ov?.componentId).toBe('oled1');
// over-voltage is non-blocking
expect(result.errors.map((e) => e.code)).not.toContain('over-voltage');
},
);
it(
'does NOT warn when the same module is fed a safe 5 V on VIN',
{ timeout: 30_000 },
async () => {
const input: BuildNetlistInput = {
components: [pwr('src', 5), part('oled2', 'ssd1306')],
wires: [
w('w1', ['src', 'SIG'], ['oled2', 'VIN']),
w('w2', ['oled2', 'GND'], ['src', 'GND']),
],
boards: [],
analysis: { kind: 'op' },
};
const result = await verifyCircuit(input);
expect(result.warnings.map((x) => x.code)).not.toContain('over-voltage');
},
);
it(
'warns when a strict 3.3 V pin (SSD1306 3V3) sits on a 5 V rail',
{ timeout: 30_000 },
async () => {
// VCC-like pin names (VCC/VDD/3V3/5V) canonicalise to the shared
// `vcc_rail` net, which defaults to 5 V. A 10k load gives the rail a
// real path to ground so the .op solves. The OLED's 3V3 pin (abs max
// 3.6 V) on that 5 V rail must warn.
const input: BuildNetlistInput = {
components: [part('oled3', 'ssd1306'), res('rl', '10k')],
wires: [
w('w1', ['oled3', '3V3'], ['rl', '1']),
w('w2', ['rl', '2'], ['oled3', 'GND']),
],
boards: [],
analysis: { kind: 'op' },
};
const result = await verifyCircuit(input);
const ov = result.warnings.find((x) => x.code === 'over-voltage');
expect(ov, JSON.stringify(result.warnings)).toBeDefined();
expect(ov?.componentId).toBe('oled3');
},
);
});
// ── 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

View File

@ -24,6 +24,7 @@
import { buildNetlist } from '../spice/NetlistBuilder';
import { runNetlist as runSpice } from '../spice/runNetlist';
import type { BuildNetlistInput, ElectricalSolveResult } from '../spice/types';
import { COMPONENT_RATINGS } from './componentRatings';
export type WarningSeverity = 'error' | 'warning';
export type WarningCode =
@ -32,6 +33,7 @@ export type WarningCode =
| 'short-circuit'
| 'source-overload'
| 'led-overcurrent'
| 'over-voltage'
| 'resistor-overpower'
| 'led-no-current';
@ -96,7 +98,7 @@ export async function verifyCircuit(
// Run a forced .op solve so currents are scalar and deterministic.
const opInput: BuildNetlistInput = { ...input, analysis: { kind: 'op' } };
const { netlist } = buildNetlist(opInput);
const { netlist, pinNetMap } = buildNetlist(opInput);
let solve: ElectricalSolveResult | undefined;
try {
@ -262,6 +264,47 @@ export async function verifyCircuit(
}
}
// ── Rule 4: over-voltage on a rated supply pin ─────────────────────────
// Components with a rated input voltage (sensors, displays, NeoPixels, …)
// warn when their supply pin carries more than the datasheet absolute
// maximum — the "fed too much voltage" mistake (e.g. a 3.3 V module wired to
// a 9 V battery). Non-blocking: the solve is still meaningful, but the real
// part would be damaged, so we surface it and flag that this operating point
// isn't emulated accurately. (Boards aren't checked yet — BoardForSpice
// doesn't carry its boardKind; that's a follow-up.)
for (const comp of input.components) {
const rating = COMPONENT_RATINGS[comp.metadataId];
if (!rating) continue;
// Ground reference: first wired gnd pin, else circuit ground (0 V).
let gndV = 0;
for (const g of rating.gndPins) {
const gnet = pinNetMap.get(`${comp.id}:${g}`);
if (gnet !== undefined) {
gndV = gnet === '0' ? 0 : (solve.nodeVoltages[gnet] ?? 0);
break;
}
}
for (const sp of rating.supplyPins) {
const net = pinNetMap.get(`${comp.id}:${sp.name}`);
if (net === undefined || net === '0') continue; // pin not wired / tied to GND
const sv = solve.nodeVoltages[net];
if (sv === undefined || !Number.isFinite(sv)) continue; // net floating / unsolved
const v = Math.abs(sv - gndV);
if (v > sp.absMaxVoltage) {
warnings.push({
severity: 'warning',
code: 'over-voltage',
componentId: comp.id,
message: `${rating.label} ${comp.id} is seeing ${formatVolts(v)} on its ${sp.name} pin — above its ${formatVolts(
sp.absMaxVoltage,
)} absolute maximum. Real hardware would likely be damaged; this voltage is not emulated accurately. Use a level shifter or the correct supply voltage.`,
metric: v,
});
break; // one over-voltage warning per part is enough
}
}
}
return {
errors,
warnings,
@ -279,6 +322,12 @@ function formatAmps(a: number): string {
return `${a.toExponential(2)} A`;
}
function formatVolts(v: number): string {
if (v >= 1) return `${v.toFixed(1)} V`;
if (v >= 1e-3) return `${(v * 1e3).toFixed(0)} mV`;
return `${v.toExponential(2)} V`;
}
function formatPower(w: number): string {
if (w >= 1) return `${w.toFixed(2)} W`;
return `${(w * 1e3).toFixed(0)} mW`;

View File

@ -0,0 +1,109 @@
/**
* Absolute-maximum supply-voltage ratings for components/boards that have a
* rated input voltage. The circuit verifier reads the solved voltage across
* each part's supply pin (vs its ground pin) and warns non-blocking when
* it exceeds the datasheet absolute maximum. This is the classic "fed too much
* voltage" mistake: a 3.3 V module or sensor wired straight to a 9 V battery.
*
* Per-PIN thresholds (not one per part): a board's 3V3 pin tolerates far less
* than its VIN pin, so each supply pin carries its own absMax. The first
* supply pin that is actually wired AND has a solved voltage is checked; a
* pin that isn't wired (or whose net is floating) is skipped. Ground is the
* first wired gnd pin, falling back to circuit ground (net "0").
*
* Thresholds are ABSOLUTE maximums chosen to NOT fire on normal 3.3 V / 5 V
* use (e.g. a 3V3 pin warns above ~3.6 V, a 5 V/VIN pin above ~6 V) so a
* warning means real hardware would likely be damaged. Adding a part here is
* safe an unknown metadataId / boardKind is simply not checked.
*/
export interface SupplyPin {
/** Exact pin name as used in wiring (case-sensitive). */
name: string;
/** Absolute-maximum voltage on this pin (vs ground) before damage, volts. */
absMaxVoltage: number;
}
export interface ComponentRating {
/** Human label used in the warning message. */
label: string;
/** Supply pins with their individual absolute-max ratings. */
supplyPins: SupplyPin[];
/** Ground reference pin name(s); falls back to circuit ground if none wired. */
gndPins: string[];
}
// Keyed by component metadataId OR board boardKind (both are checked).
export const COMPONENT_RATINGS: Record<string, ComponentRating> = {
// ── Peripheral modules (loads) — the high-value cases ──────────────────────
ssd1306: {
label: 'SSD1306 OLED',
supplyPins: [
{ name: '3V3', absMaxVoltage: 3.6 },
{ name: 'VIN', absMaxVoltage: 6 },
],
gndPins: ['GND'],
},
ili9341: { label: 'ILI9341 display', supplyPins: [{ name: 'VCC', absMaxVoltage: 6 }], gndPins: ['GND'] },
dht22: { label: 'DHT22 sensor', supplyPins: [{ name: 'VCC', absMaxVoltage: 6 }], gndPins: ['GND'] },
dht11: { label: 'DHT11 sensor', supplyPins: [{ name: 'VCC', absMaxVoltage: 5.5 }], gndPins: ['GND'] },
bmp280: { label: 'BMP280 sensor', supplyPins: [{ name: 'VCC', absMaxVoltage: 6 }], gndPins: ['GND'] },
'hc-sr04': { label: 'HC-SR04 sensor', supplyPins: [{ name: 'VCC', absMaxVoltage: 6 }], gndPins: ['GND'] },
mpu6050: { label: 'MPU6050 IMU', supplyPins: [{ name: 'VCC', absMaxVoltage: 6 }], gndPins: ['GND'] },
servo: { label: 'servo motor', supplyPins: [{ name: 'V+', absMaxVoltage: 7.2 }], gndPins: ['GND'] },
neopixel: { label: 'NeoPixel', supplyPins: [{ name: 'VDD', absMaxVoltage: 6 }], gndPins: ['VSS', 'GND'] },
// ── Boards (checked against their own supply pins) ─────────────────────────
// Each board self-drives its VCC rail to its logic voltage, so normal use
// sits below these thresholds and never warns; only genuine over-drive does.
esp32: {
label: 'ESP32',
supplyPins: [
{ name: '3V3', absMaxVoltage: 3.6 },
{ name: 'VIN', absMaxVoltage: 6 },
],
gndPins: ['GND', 'GND.1', 'GND.2'],
},
'raspberry-pi-pico': {
label: 'Raspberry Pi Pico',
supplyPins: [
{ name: '3V3', absMaxVoltage: 3.6 },
{ name: 'VBUS', absMaxVoltage: 5.5 },
{ name: 'VSYS', absMaxVoltage: 5.5 },
],
gndPins: ['GND.1', 'GND.2', 'GND.3', 'GND.4', 'GND'],
},
'pi-pico-w': {
label: 'Raspberry Pi Pico W',
supplyPins: [
{ name: '3V3', absMaxVoltage: 3.6 },
{ name: 'VBUS', absMaxVoltage: 5.5 },
{ name: 'VSYS', absMaxVoltage: 5.5 },
],
gndPins: ['GND.1', 'GND.2', 'GND.3', 'GND.4', 'GND'],
},
attiny85: { label: 'ATtiny85', supplyPins: [{ name: 'VCC', absMaxVoltage: 6 }], gndPins: ['GND'] },
'arduino-uno': {
label: 'Arduino Uno',
supplyPins: [
{ name: '5V', absMaxVoltage: 6 },
{ name: '3.3V', absMaxVoltage: 3.6 },
],
gndPins: ['GND.1', 'GND.2', 'GND.3', 'GND'],
},
'arduino-nano': {
label: 'Arduino Nano',
supplyPins: [
{ name: '5V', absMaxVoltage: 6 },
{ name: '3V3', absMaxVoltage: 3.6 },
],
gndPins: ['GND.1', 'GND.2', 'GND'],
},
'arduino-mega': {
label: 'Arduino Mega',
supplyPins: [
{ name: '5V', absMaxVoltage: 6 },
{ name: '3.3V', absMaxVoltage: 3.6 },
],
gndPins: ['GND.1', 'GND.2', 'GND.3', 'GND.4', 'GND'],
},
};