2026-05-13 02:55:15 +07:00
/ * *
* 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 / p o w e r t i e .
*
* 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' ;
2026-05-13 02:55:15 +07:00
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'
2026-05-13 02:55:15 +07:00
| '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 > ( ) ;
2026-05-13 02:55:15 +07:00
// 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 ) ;
2026-05-13 02:55:15 +07:00
}
}
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 ──────────────────────
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.
2026-05-13 02:55:15 +07:00
const sourceComponents = input . components . filter ( ( c ) = >
2026-05-18 09:55:37 +07:00
/^(battery|signal-generator|power-supply)/ . test ( c . metadataId ) ,
2026-05-13 02:55:15 +07:00
) ;
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 ;
}
2026-05-13 02:55:15 +07:00
const i = Math . abs ( branchCurrents [ ` v_ ${ src . id } ` ] ? ? 0 ) ;
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' ;
2026-05-13 02:55:15 +07:00
errors . push ( {
severity : 'error' ,
2026-05-18 09:55:37 +07:00
code : isPsu ? 'source-overload' : 'short-circuit' ,
2026-05-13 02:55:15 +07:00
componentId : src.id ,
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. ` ,
2026-05-13 02:55:15 +07:00
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 ;
}
2026-05-13 02:55:15 +07:00
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 >= 1 e - 3 ) return ` ${ ( a * 1 e3 ) . toFixed ( 1 ) } mA ` ;
if ( a >= 1 e - 6 ) return ` ${ ( a * 1 e6 ) . toFixed ( 1 ) } µA ` ;
return ` ${ a . toExponential ( 2 ) } A ` ;
}
function formatPower ( w : number ) : string {
if ( w >= 1 ) return ` ${ w . toFixed ( 2 ) } W ` ;
return ` ${ ( w * 1 e3 ) . toFixed ( 0 ) } mW ` ;
}
function formatResistance ( r : number ) : string {
if ( r >= 1 e6 ) return ` ${ ( r / 1 e6 ) . toFixed ( 1 ) } MΩ ` ;
if ( r >= 1 e3 ) return ` ${ ( r / 1 e3 ) . toFixed ( 1 ) } kΩ ` ;
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' ? 1 e - 3 : 1 ;
return base * mult ;
}