# 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: 1. **Default mode** (current behaviour): wires are visual, LEDs are boolean, `analogRead` returns whatever the last UI interaction set. No solver runs. 2. **Pure-JS electrical mode**: uses the hand-rolled MNA solver. Zero new bundle cost. Covers passive + basic non-linear. Always available. 3. **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 a `onAnyPinChange(cb)` method that fires the solver. - `frontend/src/store/useSimulatorStore.ts` — add `electricalMode: 'off' | 'mna' | 'spice'` and `nodeVoltages: Record`. ## 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_` + `.model LED_ 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=)` | | | `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 ```typescript 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 ```typescript export type ElectricalMode = 'off' | 'mna' | 'spice'; export interface ElectricalState { mode: ElectricalMode; nodeVoltages: Record; // net name → volts componentCurrents: Record; // component id → A converged: boolean; lastError: string | null; lastSolveMs: number; // performance telemetry } interface SimulatorStore { // ...existing fields... electrical: ElectricalState; setElectricalMode: (m: ElectricalMode) => void; runElectricalSolve: () => Promise; // triggered by the scheduler } ``` ### Solver scheduler Use a debounced scheduler to avoid re-solving on every frame: ```typescript 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`: ```typescript 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 `` consumes `electrical.nodeVoltages` and renders SVG text at each wire's midpoint showing the node voltage. Add a toggle in `EditorToolbar.tsx`: ```tsx ``` Warning badges: ```tsx {mode !== 'off' && !converged && Circuit did not converge — check for floating nodes} {currentThroughLED > 0.025 && ⚠ Overcurrent} ``` ### LED brightness Replace the current boolean on/off with current-based brightness: ```tsx function LEDComponent({ id }) { const current = useSimulatorStore(s => s.electrical.componentCurrents[id] ?? 0); const brightness = Math.min(1, Math.max(0, current / 0.020)); return ; } ``` Requires extending `` (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()`: ```typescript // 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(); } ``` ```typescript // 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: ```typescript // 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.ts` with Union-Find on wires. - Add `electricalMode` state and the overlay toggle. - Ship **MNA-only** mode: passives, LED, potentiometer, NTC. Most tutorials benefit immediately. ### Phase 2 (3–5 days) - Add `SpiceEngine.lazy.ts` behind the "SPICE-accurate" toggle. - Implement `AVRSpiceBridge.ts`. - Expand component mapping to MOSFETs, BJTs, diodes with real vendor models. - Telemetry: measure `lastSolveMs` to identify slow circuits. ### Phase 3 (1–2 weeks) - Vendor model library: LM358, 555, TL072, common MOSFETs — shipped as `.subckt` strings. - 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 1. **39 MB bundle impact** — mitigated by lazy-loading behind feature flag. 2. **ngspice hangs on bad netlists** (singular matrix) — netlist builder must validate floating nodes before emitting. 3. **Stale MNA solver** — limited device support; not a replacement for ngspice, only a fallback. 4. **Performance regressions on huge circuits** — schedule debounced, offload to Web Worker if > 100 ms. 5. **Component metadata drift** — when new components are added to Velxio, the solver mapping must be updated. Consider auto-validating via a unit test: every `metadataId` must have either a mapper entry or an explicit "skip" annotation.