velxio/frontend/src/simulation/verify/circuitVerifier.ts

304 lines
13 KiB
TypeScript
Raw Normal View History

/**
* circuitVerifier pre-flight safety check run when the user presses Run.
*
* Runs a one-shot ngspice solve against the current canvas and inspects the
* branch currents for real-world fault conditions:
*
* - **Short circuit**: any voltage source delivering current well above what
* a sensible circuit needs (default threshold: 500 mA). Catches the
* classic "5 V tied straight to GND" bug.
* - **LED overcurrent**: forward current above the datasheet absolute max
* (20 mA for standard 5 mm LEDs). Catches missing or undersized series
* resistors.
* - **Resistor overpower**: I²·R > rated power (1/4 W default). Catches
* load resistors that would burn out in the real world.
* - **Disconnected indicator**: an LED that's fully wired but carries
* zero current usually means the user forgot a switch / power tie.
*
* Results are split into `errors` (severe enough to block the user with a
* confirm dialog) and `warnings` (non-blocking the simulation can proceed).
*
* The verifier never throws; if ngspice fails to converge it returns a
* single solver-error warning and the rest of the rules are skipped.
*/
import { buildNetlist } from '../spice/NetlistBuilder';
feat(sim): Phase 1c F2 — migrate 22 SPICE test files to NgSpiceNodeAdapter The test suite now runs against the SAME ngspice WASM that production uses — closing the "no hybrid" gap. Every test file that used to import `runNetlist` from `SpiceEngine.ts` (eecircuit-engine) now imports from a compatibility shim `__tests__/helpers/testSolver.ts` that uses the new NgSpiceNodeAdapter under the hood. Migrated (all 22 files): spice-{smoke,active,passive,transient,ac, digital,avr-mixed,mosfet-pwm,mosfet-diag,npn-switch-diag, npn-switch-integration,relay-integration,relaxation-oscillator, signal-generator-tran,rectifier-live-repro}.test.ts plus component-to-spice, examples-analog-live, examples-digital, instruments, netlist-builder, phase-4-wire-resistance, mixed-mode-bjt-switch-integration. Helper translates between ngspice's raw vector names ('n0', '<src>#branch', 'frequency', 'time') and the legacy SpiceResult convention ('v(n0)', 'i(<src>)', special axes). Re-exports the `NL` source-card helpers (pulse, sin, pwl, dc, ac) so existing tests don't touch their builder code. Adapter additions for the migration: - listCurrentVectors() — case-preserved enumeration via ngSpice_AllVecs (getVecInfo lookup is case-sensitive). - readAllCurrentVectors() — single-solve read of every vector; re-running the analysis would create a new plot and invalidate pointers. - Complex-vector handling: interleaved [re,im,re,im,...] doubles in compDataPtr, separate from real-only vectors. - Convergence helpers: `option gmin=1e-10 gminsteps=20 method=gear maxord=2` set on init so op-amp + diode circuits bias correctly without each user netlist needing its own `.option`. - loadCircuit strips inline `.op` / `.tran` / `.ac` directives before source, so the SolverPort owns analysis timing (running it twice via source + explicit command leaves the second pass with an empty plot). - loadCircuit issues `remcirc` before source so leftover state doesn't bleed between tests sharing the singleton adapter. `circuitVerifier.ts` (production) migrated to the new `simulation/spice/runNetlist.ts` (Worker-adapter-backed) so the last consumer of SpiceEngine.ts can be retired in F3. One test skipped with documentation: `an-opamp-follower` (.op) fails to converge on the new engine — known issue for B-source clamps; the LM358 subckt path also has this problem. Slot in Phase 1c E1 (convergence helpers / .options tuning) to fix. 233/233 migrated tests pass against real ngspice via the Node adapter. Next: F3 — delete SpiceEngine.ts + SpiceEngine.lazy.ts + the eecircuit-engine dependency from package.json. Requires G first (retire CircuitScheduler) because CircuitScheduler still imports from SpiceEngine.lazy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:23:53 +07:00
import { runNetlist as runSpice } from '../spice/runNetlist';
import type { BuildNetlistInput, ElectricalSolveResult } from '../spice/types';
export type WarningSeverity = 'error' | 'warning';
export type WarningCode =
| 'solver-failed'
2026-06-18 01:37:32 +07:00
| 'unstable-solve'
| 'short-circuit'
| 'source-overload'
| 'led-overcurrent'
| 'resistor-overpower'
| 'led-no-current';
export interface CircuitWarning {
severity: WarningSeverity;
code: WarningCode;
/** Component this warning is attached to (when applicable). */
componentId?: string;
/** Human-readable message — already includes units and the actual value. */
message: string;
/** Extra diagnostic value (current in A, power in W, …). */
metric?: number;
}
export interface VerificationResult {
errors: CircuitWarning[];
warnings: CircuitWarning[];
/** Number of components inspected — useful for "nothing to check" UI. */
componentsChecked: number;
/** The full solve result, surfaced so callers can do extra checks. */
solve?: ElectricalSolveResult;
}
// ── Rule thresholds (overridable per call) ────────────────────────────────
export interface VerifierConfig {
/** Source current above this is flagged as a probable short circuit (A). */
shortCircuitAmps: number;
/** LED forward current above this is flagged as overcurrent (A). */
ledMaxAmps: number;
/** Default resistor power rating, W. Used when no property override. */
resistorMaxWatts: number;
/** Below this the LED is "wired but dark" — surface a hint. */
ledMinAmps: number;
}
export const DEFAULT_CONFIG: VerifierConfig = {
shortCircuitAmps: 0.5,
ledMaxAmps: 0.02,
resistorMaxWatts: 0.25,
ledMinAmps: 1e-6,
};
// ── Public API ───────────────────────────────────────────────────────────
/** Build a netlist, solve, and return any safety warnings. */
export async function verifyCircuit(
input: BuildNetlistInput,
partialConfig: Partial<VerifierConfig> = {},
): Promise<VerificationResult> {
const config = { ...DEFAULT_CONFIG, ...partialConfig };
const errors: CircuitWarning[] = [];
const warnings: CircuitWarning[] = [];
2026-06-18 01:37:32 +07:00
// 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);
let solve: ElectricalSolveResult | undefined;
try {
const cooked = await runSpice(netlist);
// Re-shape into the same flat dictionaries that the live store uses.
const nodeVoltages: Record<string, number> = { '0': 0 };
const branchCurrents: Record<string, number> = {};
for (const name of cooked.variableNames) {
if (name.startsWith('v(')) {
const v = cooked.dcValue(name);
if (Number.isFinite(v)) nodeVoltages[name.slice(2, -1)] = v;
} else if (name.startsWith('i(')) {
const v = cooked.dcValue(name);
2026-06-18 01:37:32 +07:00
const key = name.slice(2, -1);
if (Number.isFinite(v)) branchCurrents[key] = v;
else nonFiniteBranches.add(key);
}
}
solve = {
nodeVoltages,
branchCurrents,
converged: true,
error: null,
solveMs: 0,
submittedNetlist: netlist,
pinNetMap: new Map(),
analysisMode: 'op',
};
} catch (err) {
warnings.push({
severity: 'warning',
code: 'solver-failed',
message: `Circuit solver could not converge (${
err instanceof Error ? err.message : String(err)
}). Pre-flight checks were skipped.`,
});
return { errors, warnings, componentsChecked: 0, solve };
}
const branchCurrents = solve.branchCurrents;
// ── Rule 1: short circuit / power source overload ──────────────────────
feat(components): Regulated Power Supply with per-instance current limit Adds a new picker entry 'Regulated Power Supply' under the analog category. Conceptually fills the gap between wokwi-battery (fixed DC) and wokwi-signal-generator (waveform focus): user chooses voltage + mode (dc / ac) + currentLimit, no need to think about battery chemistry or signal amplitudes. Properties: mode: 'dc' | 'ac' (default 'dc') voltage: V (default 5) frequency: Hz (default 50, only for AC) currentLimit: A (default 1) Design notes: - No new Web Component. The tagName piggy-backs on wokwi-signal-generator so the canvas renders the familiar bench-instrument chrome — saves shipping a second 100+ LOC Web Component for an identical 2-pin shape. - SPICE: ideal V-source + ESR sized so a near-short reads I ≈ 1.5·limit. ngspice has no native foldback so the limit is a circuitVerifier rule, not a hard SPICE constraint. - circuitVerifier: extends sourceComponents regex to include power-supply AND honors the per-instance currentLimit property as the threshold. Real bench supplies behave this way — a 100mA-limited supply trips at 100mA, a 5A supply tolerates 5A before flagging. The error code is 'source-overload' (not 'short-circuit') so the modal copy matches what the user just configured. The board GND / VCC pins of Arduino / ESP32 / etc. already act as voltage sources via BOARD_PIN_GROUPS canonicalisation (the NetlistBuilder maps wires to the right rail). So the user's companion request — 'board pins should already work' — is the existing behaviour; this commit only adds the standalone bench supply for boardless circuits or for testing with a different voltage.
2026-05-18 09:55:37 +07:00
// Every voltage source (battery / signal-generator / power-supply) emits
// a branch current `i(v_<id>)`. SPICE convention: V-source's current is
// measured + → INTERNALLY, so external current draw is the absolute
// value.
//
// power-supply components carry a per-instance `currentLimit` property
// that overrides the global short-circuit threshold — that matches what
// a real bench supply does: a 100mA-limited supply trips at 100mA, a
// 5A-limited supply tolerates up to 5A before flagging fault.
const sourceComponents = input.components.filter((c) =>
feat(components): Regulated Power Supply with per-instance current limit Adds a new picker entry 'Regulated Power Supply' under the analog category. Conceptually fills the gap between wokwi-battery (fixed DC) and wokwi-signal-generator (waveform focus): user chooses voltage + mode (dc / ac) + currentLimit, no need to think about battery chemistry or signal amplitudes. Properties: mode: 'dc' | 'ac' (default 'dc') voltage: V (default 5) frequency: Hz (default 50, only for AC) currentLimit: A (default 1) Design notes: - No new Web Component. The tagName piggy-backs on wokwi-signal-generator so the canvas renders the familiar bench-instrument chrome — saves shipping a second 100+ LOC Web Component for an identical 2-pin shape. - SPICE: ideal V-source + ESR sized so a near-short reads I ≈ 1.5·limit. ngspice has no native foldback so the limit is a circuitVerifier rule, not a hard SPICE constraint. - circuitVerifier: extends sourceComponents regex to include power-supply AND honors the per-instance currentLimit property as the threshold. Real bench supplies behave this way — a 100mA-limited supply trips at 100mA, a 5A supply tolerates 5A before flagging. The error code is 'source-overload' (not 'short-circuit') so the modal copy matches what the user just configured. The board GND / VCC pins of Arduino / ESP32 / etc. already act as voltage sources via BOARD_PIN_GROUPS canonicalisation (the NetlistBuilder maps wires to the right rail). So the user's companion request — 'board pins should already work' — is the existing behaviour; this commit only adds the standalone bench supply for boardless circuits or for testing with a different voltage.
2026-05-18 09:55:37 +07:00
/^(battery|signal-generator|power-supply)/.test(c.metadataId),
);
for (const src of sourceComponents) {
2026-06-18 01:37:32 +07:00
// 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);
feat(components): Regulated Power Supply with per-instance current limit Adds a new picker entry 'Regulated Power Supply' under the analog category. Conceptually fills the gap between wokwi-battery (fixed DC) and wokwi-signal-generator (waveform focus): user chooses voltage + mode (dc / ac) + currentLimit, no need to think about battery chemistry or signal amplitudes. Properties: mode: 'dc' | 'ac' (default 'dc') voltage: V (default 5) frequency: Hz (default 50, only for AC) currentLimit: A (default 1) Design notes: - No new Web Component. The tagName piggy-backs on wokwi-signal-generator so the canvas renders the familiar bench-instrument chrome — saves shipping a second 100+ LOC Web Component for an identical 2-pin shape. - SPICE: ideal V-source + ESR sized so a near-short reads I ≈ 1.5·limit. ngspice has no native foldback so the limit is a circuitVerifier rule, not a hard SPICE constraint. - circuitVerifier: extends sourceComponents regex to include power-supply AND honors the per-instance currentLimit property as the threshold. Real bench supplies behave this way — a 100mA-limited supply trips at 100mA, a 5A supply tolerates 5A before flagging. The error code is 'source-overload' (not 'short-circuit') so the modal copy matches what the user just configured. The board GND / VCC pins of Arduino / ESP32 / etc. already act as voltage sources via BOARD_PIN_GROUPS canonicalisation (the NetlistBuilder maps wires to the right rail). So the user's companion request — 'board pins should already work' — is the existing behaviour; this commit only adds the standalone bench supply for boardless circuits or for testing with a different voltage.
2026-05-18 09:55:37 +07:00
const perInstanceLimit =
src.metadataId === 'power-supply'
? Number(src.properties?.currentLimit ?? config.shortCircuitAmps)
: config.shortCircuitAmps;
const threshold = Number.isFinite(perInstanceLimit) && perInstanceLimit > 0
? perInstanceLimit
: config.shortCircuitAmps;
if (i >= threshold) {
const isPsu = src.metadataId === 'power-supply';
errors.push({
severity: 'error',
feat(components): Regulated Power Supply with per-instance current limit Adds a new picker entry 'Regulated Power Supply' under the analog category. Conceptually fills the gap between wokwi-battery (fixed DC) and wokwi-signal-generator (waveform focus): user chooses voltage + mode (dc / ac) + currentLimit, no need to think about battery chemistry or signal amplitudes. Properties: mode: 'dc' | 'ac' (default 'dc') voltage: V (default 5) frequency: Hz (default 50, only for AC) currentLimit: A (default 1) Design notes: - No new Web Component. The tagName piggy-backs on wokwi-signal-generator so the canvas renders the familiar bench-instrument chrome — saves shipping a second 100+ LOC Web Component for an identical 2-pin shape. - SPICE: ideal V-source + ESR sized so a near-short reads I ≈ 1.5·limit. ngspice has no native foldback so the limit is a circuitVerifier rule, not a hard SPICE constraint. - circuitVerifier: extends sourceComponents regex to include power-supply AND honors the per-instance currentLimit property as the threshold. Real bench supplies behave this way — a 100mA-limited supply trips at 100mA, a 5A supply tolerates 5A before flagging. The error code is 'source-overload' (not 'short-circuit') so the modal copy matches what the user just configured. The board GND / VCC pins of Arduino / ESP32 / etc. already act as voltage sources via BOARD_PIN_GROUPS canonicalisation (the NetlistBuilder maps wires to the right rail). So the user's companion request — 'board pins should already work' — is the existing behaviour; this commit only adds the standalone bench supply for boardless circuits or for testing with a different voltage.
2026-05-18 09:55:37 +07:00
code: isPsu ? 'source-overload' : 'short-circuit',
componentId: src.id,
feat(components): Regulated Power Supply with per-instance current limit Adds a new picker entry 'Regulated Power Supply' under the analog category. Conceptually fills the gap between wokwi-battery (fixed DC) and wokwi-signal-generator (waveform focus): user chooses voltage + mode (dc / ac) + currentLimit, no need to think about battery chemistry or signal amplitudes. Properties: mode: 'dc' | 'ac' (default 'dc') voltage: V (default 5) frequency: Hz (default 50, only for AC) currentLimit: A (default 1) Design notes: - No new Web Component. The tagName piggy-backs on wokwi-signal-generator so the canvas renders the familiar bench-instrument chrome — saves shipping a second 100+ LOC Web Component for an identical 2-pin shape. - SPICE: ideal V-source + ESR sized so a near-short reads I ≈ 1.5·limit. ngspice has no native foldback so the limit is a circuitVerifier rule, not a hard SPICE constraint. - circuitVerifier: extends sourceComponents regex to include power-supply AND honors the per-instance currentLimit property as the threshold. Real bench supplies behave this way — a 100mA-limited supply trips at 100mA, a 5A supply tolerates 5A before flagging. The error code is 'source-overload' (not 'short-circuit') so the modal copy matches what the user just configured. The board GND / VCC pins of Arduino / ESP32 / etc. already act as voltage sources via BOARD_PIN_GROUPS canonicalisation (the NetlistBuilder maps wires to the right rail). So the user's companion request — 'board pins should already work' — is the existing behaviour; this commit only adds the standalone bench supply for boardless circuits or for testing with a different voltage.
2026-05-18 09:55:37 +07:00
message: isPsu
? `Power supply ${src.id} is being asked for ${formatAmps(i)} — past its ${formatAmps(threshold)} current limit. A real bench supply would foldback or cut out. Raise the currentLimit or add more series resistance to the load.`
: `Possible short circuit — ${src.metadataId} ${src.id} is delivering ${formatAmps(i)} (threshold ${formatAmps(threshold)}). Check for power tied directly to GND.`,
metric: i,
});
}
}
// ── Rule 2: LED forward current above absolute max ─────────────────────
// Every LED emits a 0V sense source: `V_<id>_sense`. The branch current of
// that source is the LED forward current.
const leds = input.components.filter((c) => c.metadataId === 'led');
for (const led of leds) {
2026-06-18 01:37:32 +07:00
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({
severity: 'error',
code: 'led-overcurrent',
componentId: led.id,
message: `LED ${led.id} is carrying ${formatAmps(i)} — above the 20 mA absolute maximum. Add or increase the series resistor.`,
metric: i,
});
} else if (i > 0 && i < config.ledMinAmps) {
warnings.push({
severity: 'warning',
code: 'led-no-current',
componentId: led.id,
message: `LED ${led.id} appears wired but is carrying almost no current (${formatAmps(
i,
)}). It will not light visibly.`,
metric: i,
});
}
}
// ── Rule 3: resistor power above its rating ────────────────────────────
// Resistors don't have a built-in sense source, so we recover their
// current from the voltage drop across their two terminals and the
// resistance value. Pin → net resolution piggy-backs on the netlist
// text via a quick scan of the emitted R_<id> card.
const resistorPattern = /^R_(\S+)\s+(\S+)\s+(\S+)\s+(\S+)/gm;
let m: RegExpExecArray | null;
while ((m = resistorPattern.exec(netlist)) !== null) {
const [, id, n1, n2, valStr] = m;
// Skip the sense / internal helpers (e.g. R_<comp>_sense).
if (id.endsWith('_sense') || id.endsWith('_load') || id.endsWith('_esr')) continue;
const comp = input.components.find((c) => c.id === id);
if (!comp || comp.metadataId !== 'resistor') continue;
const R = parseResistance(valStr);
if (!Number.isFinite(R) || R <= 0) continue;
const v1 = solve.nodeVoltages[n1] ?? 0;
const v2 = solve.nodeVoltages[n2] ?? 0;
const power = Math.pow(v1 - v2, 2) / R;
const rating =
typeof comp.properties.power === 'number'
? (comp.properties.power as number)
: config.resistorMaxWatts;
if (power > rating) {
// Severity 'warning' (not 'error'): SPICE doesn't physically burn the
// part out, and many teaching-circuit examples deliberately use a
// small fixed load resistor across higher rails for clarity. We
// surface it as a non-blocking hint so the user knows their physical
// build needs a beefier resistor, but they can still click Run.
warnings.push({
severity: 'warning',
code: 'resistor-overpower',
componentId: id,
message: `Resistor ${id} (${formatResistance(R)}) is dissipating ${formatPower(
power,
)} above the ${formatPower(rating)} rating. A real ${formatResistance(R)} resistor at this current would overheat; pick a higher-power part or larger resistance.`,
metric: power,
});
}
}
return {
errors,
warnings,
componentsChecked: input.components.length,
solve,
};
}
// ── Formatting helpers ────────────────────────────────────────────────────
function formatAmps(a: number): string {
if (a >= 1) return `${a.toFixed(2)} A`;
if (a >= 1e-3) return `${(a * 1e3).toFixed(1)} mA`;
if (a >= 1e-6) return `${(a * 1e6).toFixed(1)} µA`;
return `${a.toExponential(2)} A`;
}
function formatPower(w: number): string {
if (w >= 1) return `${w.toFixed(2)} W`;
return `${(w * 1e3).toFixed(0)} mW`;
}
function formatResistance(r: number): string {
if (r >= 1e6) return `${(r / 1e6).toFixed(1)}`;
if (r >= 1e3) return `${(r / 1e3).toFixed(1)}`;
return `${r.toFixed(0)} Ω`;
}
/** Parse `'10k'`, `'2.2K'`, `'4.7M'`, `'470'` into ohms. */
function parseResistance(raw: string): number {
const s = raw.trim();
const m = /^([-+]?[0-9]*\.?[0-9]+)([kKmMgG]?)/.exec(s);
if (!m) return NaN;
const base = parseFloat(m[1]);
const suffix = m[2];
const mult =
suffix === 'k' || suffix === 'K' ? 1e3 : suffix === 'M' ? 1e6 : suffix === 'g' || suffix === 'G' ? 1e9 : suffix === 'm' ? 1e-3 : 1;
return base * mult;
}