feat: electrical simulation via ngspice-WASM (eecircuit-engine)
Adds full SPICE-accurate electrical simulation to Velxio, behind a lazy-
loaded ⚡ toolbar toggle. Arduino / ESP32 / RP2040 sketches now co-simulate
with real analog behaviour: correct voltages on wires, real I–V curves on
LEDs, working potentiometers, NTC thermistors read by analogRead(), PWM
driving RC filters, transistors, op-amps, diodes, MOSFETs, etc.
Engine: eecircuit-engine (ngspice compiled to WebAssembly). Main bundle
stays at 2.4 MB; the 20 MB SPICE chunk only loads when the user activates
electrical mode. Disabled at build time via VITE_ELECTRICAL_SIM=false.
Frontend additions:
- simulation/spice/: SpiceEngine wrapper + lazy entry, NetlistBuilder with
UnionFind over wires, componentToSpice mapping (24 metadataIds incl.
real part numbers: 2N2222, 2N3055, BC547, IRF540, 2N7000, 1N4148,
1N4007, 1N4733, LEDs, NTC, op-amp ideal), CircuitScheduler with
debounced coalescing, AVRSpiceBridge for quasi-static co-simulation.
- store/useElectricalStore: Zustand slice, feature-flag aware.
- components/analog-ui/: ⚡ toolbar toggle + SVG voltage overlay.
- components/components-instruments/: Voltmeter, Ammeter probes.
- 62 tests (spice-*, netlist-builder, component-to-spice, instruments).
Sandbox (test/test_circuit/): 47-test validation sandbox that proved
the approach (hand-rolled MNA baseline + ngspice pipeline) before
porting to the app. Kept as reference.
Docs: docs/wiki/circuit-emulation-*.md (13 engineering pages covering
architecture, solvers, components, AVR bridge, gotchas, performance,
integration plan, API reference, appendix) + electrical-simulation-
user-guide.md (end-user facing).
Reference plan: test/test_circuit/plan/phase_8_velxio_implementation.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:11:54 +07:00
|
|
|
/**
|
|
|
|
|
* SpiceEngine — thin wrapper around eecircuit-engine (ngspice-WASM).
|
|
|
|
|
*
|
|
|
|
|
* This is the **non-lazy** module. It imports `eecircuit-engine` directly
|
|
|
|
|
* and is therefore pulled into every bundle that touches it. Production
|
|
|
|
|
* code should import from `./SpiceEngine.lazy` instead, which code-splits
|
|
|
|
|
* the 39 MB dependency behind a dynamic `import()`.
|
|
|
|
|
*
|
|
|
|
|
* See plan: test/test_circuit/plan/phase_8_velxio_implementation.md
|
|
|
|
|
* See sandbox: test/test_circuit/src/spice/SpiceEngine.js
|
|
|
|
|
*/
|
|
|
|
|
import { Simulation, type ResultType, type ComplexNumber } from 'eecircuit-engine';
|
|
|
|
|
|
|
|
|
|
/** One complex number, re-exported for convenience. */
|
|
|
|
|
export type { ResultType, ComplexNumber };
|
|
|
|
|
|
|
|
|
|
/** Per-variable data array returned by `vec()`. */
|
|
|
|
|
export type VectorValue = number | ComplexNumber;
|
|
|
|
|
|
|
|
|
|
/** Cooked result returned by `runNetlist()`. */
|
|
|
|
|
export interface SpiceResult {
|
|
|
|
|
/** The underlying ngspice result — use this for edge cases. */
|
|
|
|
|
raw: ResultType;
|
|
|
|
|
/** Array of every variable name ngspice returned. */
|
|
|
|
|
variableNames: string[];
|
|
|
|
|
/** Returns the full data array for a variable. For `.ac` sweeps it's `ComplexNumber[]`; otherwise `number[]`. */
|
|
|
|
|
vec(name: string): VectorValue[];
|
|
|
|
|
/** Returns the first data point for a variable (useful for `.op`). */
|
|
|
|
|
dcValue(name: string): number;
|
|
|
|
|
/** Returns the last data point for a variable. */
|
|
|
|
|
vAtLast(name: string): VectorValue;
|
|
|
|
|
/** Resolves a variable name (case-insensitive, auto-wraps `v()`) to an index, or -1. */
|
|
|
|
|
findVar(name: string): number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Singleton boot ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
let singleton: Simulation | null = null;
|
|
|
|
|
let bootPromise: Promise<Simulation> | null = null;
|
|
|
|
|
|
|
|
|
|
async function bootEngine(): Promise<Simulation> {
|
|
|
|
|
if (singleton) return singleton;
|
|
|
|
|
if (bootPromise) return bootPromise;
|
|
|
|
|
|
|
|
|
|
bootPromise = (async () => {
|
|
|
|
|
const t0 = performance.now();
|
|
|
|
|
const sim = new Simulation();
|
|
|
|
|
await sim.start();
|
|
|
|
|
singleton = sim;
|
|
|
|
|
const dt = performance.now() - t0;
|
2026-04-22 03:14:27 +07:00
|
|
|
|
feat: electrical simulation via ngspice-WASM (eecircuit-engine)
Adds full SPICE-accurate electrical simulation to Velxio, behind a lazy-
loaded ⚡ toolbar toggle. Arduino / ESP32 / RP2040 sketches now co-simulate
with real analog behaviour: correct voltages on wires, real I–V curves on
LEDs, working potentiometers, NTC thermistors read by analogRead(), PWM
driving RC filters, transistors, op-amps, diodes, MOSFETs, etc.
Engine: eecircuit-engine (ngspice compiled to WebAssembly). Main bundle
stays at 2.4 MB; the 20 MB SPICE chunk only loads when the user activates
electrical mode. Disabled at build time via VITE_ELECTRICAL_SIM=false.
Frontend additions:
- simulation/spice/: SpiceEngine wrapper + lazy entry, NetlistBuilder with
UnionFind over wires, componentToSpice mapping (24 metadataIds incl.
real part numbers: 2N2222, 2N3055, BC547, IRF540, 2N7000, 1N4148,
1N4007, 1N4733, LEDs, NTC, op-amp ideal), CircuitScheduler with
debounced coalescing, AVRSpiceBridge for quasi-static co-simulation.
- store/useElectricalStore: Zustand slice, feature-flag aware.
- components/analog-ui/: ⚡ toolbar toggle + SVG voltage overlay.
- components/components-instruments/: Voltmeter, Ammeter probes.
- 62 tests (spice-*, netlist-builder, component-to-spice, instruments).
Sandbox (test/test_circuit/): 47-test validation sandbox that proved
the approach (hand-rolled MNA baseline + ngspice pipeline) before
porting to the app. Kept as reference.
Docs: docs/wiki/circuit-emulation-*.md (13 engineering pages covering
architecture, solvers, components, AVR bridge, gotchas, performance,
integration plan, API reference, appendix) + electrical-simulation-
user-guide.md (end-user facing).
Reference plan: test/test_circuit/plan/phase_8_velxio_implementation.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:11:54 +07:00
|
|
|
console.log(`[SpiceEngine] boot in ${dt.toFixed(0)}ms`);
|
|
|
|
|
return sim;
|
|
|
|
|
})();
|
|
|
|
|
return bootPromise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Returns the (lazy-booted) ngspice Simulation singleton. */
|
|
|
|
|
export async function getEngine(): Promise<Simulation> {
|
|
|
|
|
return bootEngine();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** True if the engine has finished booting (i.e., `runSim` is safe). */
|
|
|
|
|
export function isEngineReady(): boolean {
|
|
|
|
|
return singleton !== null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Main API ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Submit a netlist, run it, return cooked results.
|
|
|
|
|
*
|
|
|
|
|
* ngspice is serial: concurrent calls are serialized by the caller. If you
|
|
|
|
|
* need parallel simulation, spawn a Web Worker per instance (out of scope here).
|
|
|
|
|
*/
|
|
|
|
|
export async function runNetlist(netlist: string): Promise<SpiceResult> {
|
|
|
|
|
const sim = await bootEngine();
|
|
|
|
|
const t0 = performance.now();
|
|
|
|
|
|
|
|
|
|
sim.setNetList(netlist);
|
|
|
|
|
const raw = await sim.runSim();
|
|
|
|
|
|
|
|
|
|
const dt = performance.now() - t0;
|
|
|
|
|
if (dt > 200) {
|
|
|
|
|
console.log(`[SpiceEngine] slow solve: ${dt.toFixed(0)}ms`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lowerNames = raw.variableNames.map((n) => n.toLowerCase());
|
|
|
|
|
|
|
|
|
|
const findVar = (name: string): number => {
|
|
|
|
|
const l = name.toLowerCase();
|
|
|
|
|
let idx = lowerNames.indexOf(l);
|
|
|
|
|
if (idx >= 0) return idx;
|
|
|
|
|
idx = lowerNames.indexOf(`v(${l})`);
|
|
|
|
|
if (idx >= 0) return idx;
|
|
|
|
|
return -1;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const vec = (name: string): VectorValue[] => {
|
|
|
|
|
const idx = findVar(name);
|
|
|
|
|
if (idx < 0) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`[SpiceEngine] Variable "${name}" not found. Available: ${raw.variableNames.join(', ')}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// TS union: raw.data[idx].values is number[] | ComplexNumber[]
|
|
|
|
|
return raw.data[idx].values as VectorValue[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const dcValue = (name: string): number => {
|
|
|
|
|
const v = vec(name)[0];
|
|
|
|
|
if (typeof v === 'number') return v;
|
|
|
|
|
// Complex at DC: return the real part
|
|
|
|
|
return v.real;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const vAtLast = (name: string): VectorValue => {
|
|
|
|
|
const v = vec(name);
|
|
|
|
|
return v[v.length - 1];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
raw,
|
|
|
|
|
variableNames: raw.variableNames,
|
|
|
|
|
findVar,
|
|
|
|
|
vec,
|
|
|
|
|
dcValue,
|
|
|
|
|
vAtLast,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Netlist card helpers ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convenience builders for the most common SPICE source cards.
|
|
|
|
|
* These are pure string helpers; no side effects.
|
|
|
|
|
*/
|
|
|
|
|
export const NL = {
|
|
|
|
|
/** `Vname plus minus PULSE(v1 v2 td tr tf pw per)` */
|
|
|
|
|
pulse(
|
|
|
|
|
name: string,
|
|
|
|
|
plus: string,
|
|
|
|
|
minus: string,
|
|
|
|
|
v1: number | string,
|
|
|
|
|
v2: number | string,
|
|
|
|
|
td: number | string,
|
|
|
|
|
tr: number | string,
|
|
|
|
|
tf: number | string,
|
|
|
|
|
pw: number | string,
|
|
|
|
|
per: number | string,
|
|
|
|
|
): string {
|
|
|
|
|
return `${name} ${plus} ${minus} PULSE(${v1} ${v2} ${td} ${tr} ${tf} ${pw} ${per})`;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/** `Vname plus minus SIN(offset amp freq)` */
|
2026-04-22 02:45:45 +07:00
|
|
|
sin(
|
|
|
|
|
name: string,
|
|
|
|
|
plus: string,
|
|
|
|
|
minus: string,
|
|
|
|
|
offset: number,
|
|
|
|
|
amp: number,
|
|
|
|
|
freq: number,
|
|
|
|
|
): string {
|
feat: electrical simulation via ngspice-WASM (eecircuit-engine)
Adds full SPICE-accurate electrical simulation to Velxio, behind a lazy-
loaded ⚡ toolbar toggle. Arduino / ESP32 / RP2040 sketches now co-simulate
with real analog behaviour: correct voltages on wires, real I–V curves on
LEDs, working potentiometers, NTC thermistors read by analogRead(), PWM
driving RC filters, transistors, op-amps, diodes, MOSFETs, etc.
Engine: eecircuit-engine (ngspice compiled to WebAssembly). Main bundle
stays at 2.4 MB; the 20 MB SPICE chunk only loads when the user activates
electrical mode. Disabled at build time via VITE_ELECTRICAL_SIM=false.
Frontend additions:
- simulation/spice/: SpiceEngine wrapper + lazy entry, NetlistBuilder with
UnionFind over wires, componentToSpice mapping (24 metadataIds incl.
real part numbers: 2N2222, 2N3055, BC547, IRF540, 2N7000, 1N4148,
1N4007, 1N4733, LEDs, NTC, op-amp ideal), CircuitScheduler with
debounced coalescing, AVRSpiceBridge for quasi-static co-simulation.
- store/useElectricalStore: Zustand slice, feature-flag aware.
- components/analog-ui/: ⚡ toolbar toggle + SVG voltage overlay.
- components/components-instruments/: Voltmeter, Ammeter probes.
- 62 tests (spice-*, netlist-builder, component-to-spice, instruments).
Sandbox (test/test_circuit/): 47-test validation sandbox that proved
the approach (hand-rolled MNA baseline + ngspice pipeline) before
porting to the app. Kept as reference.
Docs: docs/wiki/circuit-emulation-*.md (13 engineering pages covering
architecture, solvers, components, AVR bridge, gotchas, performance,
integration plan, API reference, appendix) + electrical-simulation-
user-guide.md (end-user facing).
Reference plan: test/test_circuit/plan/phase_8_velxio_implementation.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:11:54 +07:00
|
|
|
return `${name} ${plus} ${minus} SIN(${offset} ${amp} ${freq})`;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/** `Vname plus minus PWL(t0 v0 t1 v1 ...)` */
|
|
|
|
|
pwl(name: string, plus: string, minus: string, pairs: Array<[number, number]>): string {
|
|
|
|
|
return `${name} ${plus} ${minus} PWL(${pairs.flat().join(' ')})`;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/** `Vname plus minus DC value` */
|
|
|
|
|
dc(name: string, plus: string, minus: string, value: number | string): string {
|
|
|
|
|
return `${name} ${plus} ${minus} DC ${value}`;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/** `Vname plus minus AC amp` (for small-signal sweeps) */
|
|
|
|
|
ac(name: string, plus: string, minus: string, amp: number | string): string {
|
|
|
|
|
return `${name} ${plus} ${minus} AC ${amp}`;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── Reset (for tests / debugging) ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Throw away the singleton and force a fresh boot on the next call.
|
|
|
|
|
* Tests can use this to isolate themselves from engine state carried over
|
|
|
|
|
* from previous simulations. Production code should not need this.
|
|
|
|
|
*/
|
|
|
|
|
export function __resetEngineForTests(): void {
|
|
|
|
|
singleton = null;
|
|
|
|
|
bootPromise = null;
|
|
|
|
|
}
|