velxio/docs/wiki/circuit-emulation-velxio-in...

362 lines
15 KiB
Markdown
Raw Normal View History

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
# 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<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
```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<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:
```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 `<CircuitVoltageOverlay />` consumes `electrical.nodeVoltages` and renders SVG text at each wire's midpoint showing the node voltage. Add a toggle in `EditorToolbar.tsx`:
```tsx
<button onClick={() => toggleElectricalOverlay()}>
⚡ {mode === 'off' ? 'Show voltages' : 'Hide voltages'}
</button>
```
Warning badges:
```tsx
{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:
```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 <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()`:
```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 (12 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 (35 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 (12 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.