15 KiB
Integrating Circuit Emulation into Velxio
Concrete steps to take what was proven in test/test_circuit/ and wire it into the main app.
Recommendation
Ship both pipelines, controlled by a user-visible toggle:
- Default mode (current behaviour): wires are visual, LEDs are boolean,
analogReadreturns whatever the last UI interaction set. No solver runs. - Pure-JS electrical mode: uses the hand-rolled MNA solver. Zero new bundle cost. Covers passive + basic non-linear. Always available.
- SPICE-accurate mode: lazy-loads
eecircuit-engine(39 MB). Full SPICE feature set. Activated by a toolbar toggle.
This gives new users zero friction, lets basic tutorials opt into passive simulation without a 39 MB download, and unlocks the full analog feature set for users who care.
Proposed file structure
frontend/src/simulation/
├── AVRSimulator.ts (existing — no changes needed)
├── PinManager.ts (existing — minor hook addition)
├── CircuitSolver.ts ★ NEW — MNA solver (port of hand-rolled)
├── CircuitComponents.ts ★ NEW — R, V, D, LED, etc.
├── SpiceEngine.ts ★ NEW — wraps eecircuit-engine (lazy)
├── SpiceEngine.lazy.ts ★ NEW — code-split, dynamic import
├── NetlistBuilder.ts ★ NEW — wires[] + components[] → netlist
├── CircuitStore.ts ★ NEW — voltages/currents overlay state
└── bridges/
├── AVRCircuitBridge.ts ★ NEW — AVR ↔ hand-rolled solver
└── AVRSpiceBridge.ts ★ NEW — AVR ↔ ngspice
Almost everything is additive. The only existing files touched:
frontend/package.json— add"eecircuit-engine": "^1.7.0"as an optional / dynamic-import dependency.frontend/src/simulation/PinManager.ts— add aonAnyPinChange(cb)method that fires the solver.frontend/src/store/useSimulatorStore.ts— addelectricalMode: 'off' | 'mna' | 'spice'andnodeVoltages: Record<string, number>.
Data flow
User edits wires/components in canvas
│
▼
┌──────────────────────────────┐
│ useSimulatorStore │
│ - components[] │
│ - wires[] │
│ - electricalMode │
└──────────────┬───────────────┘
│ (every 50 ms if dirty, or on pin change)
▼
┌──────────────────────────────┐
│ NetlistBuilder.ts │
│ (or CircuitSolver graph) │
│ 1. Union-Find on wires │
│ 2. Map metadataId → card │
│ 3. Collect AVR pin states │
└──────────────┬───────────────┘
▼
if mode === 'mna' if mode === 'spice'
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ CircuitSolver.solve()│ │ SpiceEngine.run() │
│ (always available) │ │ (lazy-loaded module) │
└──────────────┬───────┘ └──────────────┬───────┘
│ │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ nodeVoltages / currents │
│ → useSimulatorStore │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ Canvas voltage overlay │
│ LED brightness update │
│ AVR ADC channel injection │
│ Warning badges │
└──────────────────────────────┘
Mapping metadataId → SPICE primitive
Exhaustive table derived from the Velxio component catalog:
metadataId |
SPICE card | Notes |
|---|---|---|
resistor / resistor-us |
Rid na nb value |
value = parseFloat(props.resistance) |
capacitor / electrolytic-capacitor |
Cid na nb value IC=0 |
value = parseFloat(props.capacitance) |
inductor |
Lid na nb value |
value = parseFloat(props.inductance) |
led |
Did anode cathode LED_<color> + .model LED_<color> D(Is=… N=…) |
use tuned parameters from src/components/active.js |
led-5mm / led-3mm |
idem | |
diode |
Did a c DMOD + .model DMOD D(Is=1e-14 N=1) |
generic Shockley |
zener-diode |
.model DMOD D(Is=… BV=<zener voltage>) |
|
pushbutton |
Rid na nb R where R = pressed ? 0.01 : 1G |
update on button press event |
slide-potentiometer |
Two R's with wiperPos |
values recomputed on drag |
trimmer-potentiometer |
idem | |
ntc-temperature-sensor |
Rid na nb R(T) |
user sets temperature in UI |
photoresistor |
Rid na nb R(lux) |
user sets lux in UI |
dht22 |
behavioral: outputs a fixed digital pulse train matching the protocol | outside solver scope, keep current code |
ds18b20 |
behavioral: 1-Wire protocol | outside solver scope |
buzzer-active |
Rid na nb 100 (to simulate current draw) |
audio is outside solver |
buzzer-passive |
Rid + L model |
optional |
servo-horn / servo-motor |
R_coil na nb 50 |
current draw only |
dc-motor |
R_coil + L_coil |
simulated back-EMF optional |
relay |
Switch controlled by coil voltage | .model SW(Vt=3 Vh=0.1 …) |
7-segment |
8 LEDs + digit pins | each segment = separate LED |
lcd1602 |
behavioral display, NOT in solver | keep current code |
ssd1306 |
idem | |
neopixel |
idem | |
mpu6050, bmp280, etc. (I²C/SPI sensors) |
NOT in solver | keep current code |
arduino-uno GPIO (digital) |
Vid pin 0 DC {pinState?5:0} |
one V source per used pin |
arduino-uno GPIO (PWM) |
Vid pin 0 DC {duty*5} |
quasi-static (avg DC) |
arduino-uno GPIO (AC/fast PWM needed) |
PULSE(0 5 0 1u 1u {dutyTime} {period}) |
use only when user asks for waveform |
Components with NO electrical model
Keep their existing simulation path: they have state, protocols, or cyclic behaviour outside the analog solver. The netlist builder simply omits them and carries on. Their wires still exist visually but don't participate in the solve.
Netlist Builder algorithm
import { UnionFind } from './unionFind';
function buildNetlist(components, wires, avrState): string {
// 1. Union-Find to identify nets
const uf = new UnionFind();
for (const w of wires) {
const a = `${w.start.componentId}:${w.start.pinName}`;
const b = `${w.end.componentId}:${w.end.pinName}`;
uf.add(a); uf.add(b); uf.union(a, b);
}
// 2. Canonical node names — map specific pins to ground/vcc if connected
for (const pin of collectPins(components)) {
if (isGndPin(pin)) uf.setCanonical(pin, 'gnd');
else if (isVccPin(pin)) uf.setCanonical(pin, 'vcc');
}
// All other nets get auto-named: n1, n2, ... based on a stable hash
// 3. Emit cards
const lines = [`Velxio circuit @${Date.now()}`];
for (const comp of components) {
const pins = comp.pins.map(p => uf.find(`${comp.id}:${p.name}`));
const card = cardFor(comp, pins);
if (card) lines.push(card);
}
// 4. Emit AVR pin sources
for (const pin of avrState.outputPins) {
const net = uf.find(`${avrState.mcuId}:${pin.name}`);
if (!net) continue;
if (pin.type === 'pwm') lines.push(`V_${pin.name} ${net} 0 DC ${pin.duty * 5}`);
else lines.push(`V_${pin.name} ${net} 0 DC ${pin.high ? 5 : 0}`);
}
// 5. Always add a vcc/gnd source if those nets are referenced
if (uf.has('vcc') && !hasVcc(lines)) lines.push('V_VCC vcc 0 DC 5');
// 6. Add analysis card
lines.push('.op'); // or .tran / .ac based on mode
lines.push('.end');
return lines.join('\n');
}
Hooking into Velxio's existing architecture
useSimulatorStore additions
export type ElectricalMode = 'off' | 'mna' | 'spice';
export interface ElectricalState {
mode: ElectricalMode;
nodeVoltages: Record<string, number>; // net name → volts
componentCurrents: Record<string, number>; // component id → A
converged: boolean;
lastError: string | null;
lastSolveMs: number; // performance telemetry
}
interface SimulatorStore {
// ...existing fields...
electrical: ElectricalState;
setElectricalMode: (m: ElectricalMode) => void;
runElectricalSolve: () => Promise<void>; // triggered by the scheduler
}
Solver scheduler
Use a debounced scheduler to avoid re-solving on every frame:
const scheduleElectricalSolve = debounce(async () => {
const { mode, components, wires } = useSimulatorStore.getState();
if (mode === 'off') return;
const avrState = readAvrState();
const netlist = buildNetlist(components, wires, avrState);
const start = performance.now();
let result;
try {
if (mode === 'spice') {
const { runNetlist } = await import('./SpiceEngine.lazy');
result = await runNetlist(netlist);
} else {
result = CircuitSolver.solveFromNetlist(netlist); // in-tree solver
}
} catch (e) {
useSimulatorStore.setState((s) => ({
electrical: { ...s.electrical, lastError: String(e), converged: false },
}));
return;
}
const voltages = extractVoltages(result);
injectADC(avrState, voltages);
useSimulatorStore.setState((s) => ({
electrical: {
...s.electrical,
nodeVoltages: voltages,
converged: result.converged ?? true,
lastError: null,
lastSolveMs: performance.now() - start,
},
}));
}, 50);
// Triggers
pinManager.onAnyPinChange(scheduleElectricalSolve);
// When wires/components change:
useSimulatorStore.subscribe((s) => s.wires, scheduleElectricalSolve);
useSimulatorStore.subscribe((s) => s.components, scheduleElectricalSolve);
ADC injection
After every solve, if an analog pin's net is resolved, inject it into AVRADC.channelValues:
function injectADC(avr, voltages) {
for (const pin of ['a0', 'a1', 'a2', 'a3', 'a4', 'a5']) {
const net = findPinNet(avr, pin);
if (!net) continue;
const v = voltages[net];
if (v != null) {
const channel = parseInt(pin.slice(1), 10);
avr.getADC().channelValues[channel] = v;
}
}
}
UI — voltage overlay
A new React component <CircuitVoltageOverlay /> consumes electrical.nodeVoltages and renders SVG text at each wire's midpoint showing the node voltage. Add a toggle in EditorToolbar.tsx:
<button onClick={() => toggleElectricalOverlay()}>
⚡ {mode === 'off' ? 'Show voltages' : 'Hide voltages'}
</button>
Warning badges:
{mode !== 'off' && !converged && <Banner>Circuit did not converge — check for floating nodes</Banner>}
{currentThroughLED > 0.025 && <LEDWarning id={led.id}>⚠ Overcurrent</LEDWarning>}
LED brightness
Replace the current boolean on/off with current-based brightness:
function LEDComponent({ id }) {
const current = useSimulatorStore(s => s.electrical.componentCurrents[id] ?? 0);
const brightness = Math.min(1, Math.max(0, current / 0.020));
return <wokwi-led brightness={brightness} ... />;
}
Requires extending <wokwi-led> (or a React wrapper) to accept a float brightness. Velxio already passes booleans; the wokwi-elements SVG supports CSS-driven brightness.
Lazy-loading eecircuit-engine
Create a dedicated ESM module that imports it, and reference it only via await import():
// SpiceEngine.lazy.ts
import { Simulation } from 'eecircuit-engine';
let singleton: Simulation | null = null;
export async function runNetlist(netlist: string) {
if (!singleton) {
singleton = new Simulation();
await singleton.start();
}
singleton.setNetList(netlist);
return singleton.runSim();
}
// Elsewhere:
const { runNetlist } = await import('./SpiceEngine.lazy');
Vite will code-split this into a separate chunk that is only fetched when first used. The initial page load stays fast.
Feature gating
Add a config flag:
// src/config/features.ts
export const FEATURES = {
electricalSimulation: import.meta.env.VITE_ELECTRICAL_SIM !== 'false',
};
Default enabled in production; can be turned off for minimal builds.
Rollout plan
Phase 1 (1–2 days)
- Port hand-rolled MNA solver into
frontend/src/simulation/CircuitSolver.ts. - Implement
NetlistBuilder.tswith Union-Find on wires. - Add
electricalModestate and the overlay toggle. - Ship MNA-only mode: passives, LED, potentiometer, NTC. Most tutorials benefit immediately.
Phase 2 (3–5 days)
- Add
SpiceEngine.lazy.tsbehind the "SPICE-accurate" toggle. - Implement
AVRSpiceBridge.ts. - Expand component mapping to MOSFETs, BJTs, diodes with real vendor models.
- Telemetry: measure
lastSolveMsto identify slow circuits.
Phase 3 (1–2 weeks)
- Vendor model library: LM358, 555, TL072, common MOSFETs — shipped as
.subcktstrings. - Warning surface (overcurrent, floating nodes, no series R with LED).
- Noise analysis (
.noise) and frequency sweep UI (Bode plotter). - Oscilloscope "SPICE probe mode" — show real waveforms, not just digital lines.
Testing strategy for the port
Port the 47 tests from test/test_circuit/ into Velxio's vitest suite, adapting only the imports. The numerical expectations stay the same. This gives immediate validation that the integration didn't regress the solver.
Risks
- 39 MB bundle impact — mitigated by lazy-loading behind feature flag.
- ngspice hangs on bad netlists (singular matrix) — netlist builder must validate floating nodes before emitting.
- Stale MNA solver — limited device support; not a replacement for ngspice, only a fallback.
- Performance regressions on huge circuits — schedule debounced, offload to Web Worker if > 100 ms.
- Component metadata drift — when new components are added to Velxio, the solver mapping must be updated. Consider auto-validating via a unit test: every
metadataIdmust have either a mapper entry or an explicit "skip" annotation.