12 KiB
Circuit Emulation — Architecture
High-level data flow
Two pipelines coexist. Both share the same avr8js harness and the same Arduino program fixtures; they differ only in the analog solver.
┌────────────────────────── Shared ──────────────────────────┐
│ │
│ Arduino sketch source (.ino) or pre-built .hex │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ AVRHarness │ (wraps avr8js) │
│ │ (CPU + │ │
│ │ PORTs + │ │
│ │ ADC + │ │
│ │ Timers) │ │
│ └──────┬──────┘ │
│ │ │
│ pin state / PWM duty ADC channel voltages │
│ ▼ ▲ │
└──────────────────│────────────────────│─────────────────────┘
│ │
┌──────────────┼────────────────────┼──────────────┐
│ ▼ │ │
│ ┌─────────────────┐ │ │
│ │ Pipeline A │ │ │
│ │ MNA Solver (JS) │──────────┤ │
│ │ │ │ │
│ │ - Node graph │ │ │
│ │ - Stamps │ │ │
│ │ - Newton loop │ │ │
│ │ - Transient │ │ │
│ │ (back-Euler) │ │ │
│ └─────────────────┘ │ │
│ │ │
│ ┌──────────────────┐ │ │
│ │ Pipeline B │ │ │
│ │ SpiceEngine │─────────┘ │
│ │ (eecircuit-engine│ │
│ │ / ngspice-WASM) │ │
│ │ │ │
│ │ - Netlist in │ │
│ │ - ResultType out │ │
│ │ - AVRSpiceBridge │ │
│ │ for co-sim │ │
│ └──────────────────┘ │
│ │
└──────────────────────────────────────────────────┘
Module layout
test/test_circuit/
├── src/
│ ├── solver/ # Pipeline A — hand-rolled MNA
│ │ ├── linalg.js # Gaussian elimination with partial pivoting
│ │ └── Circuit.js # Circuit class, DC + transient solve
│ ├── components/ # Component library for Pipeline A
│ │ ├── passive.js # R, V, I, C, Pot, NTC, Switch
│ │ └── active.js # Diode, LED, BJT
│ ├── avr/ # Shared AVR harness
│ │ ├── intelHex.js # Intel HEX parser (same format as Velxio)
│ │ ├── AVRHarness.js # Thin wrapper over avr8js
│ │ ├── asm.js # Mini AVR assembler (LDI, OUT, STS, LDS, RJMP, SBRC/S, NOP)
│ │ └── programs.js # Hand-assembled Arduino programs (pot→PWM, adcRead)
│ ├── spice/ # Pipeline B — ngspice integration
│ │ ├── SpiceEngine.js # runNetlist() + vec()/dcValue() helpers
│ │ └── AVRSpiceBridge.js # Quasi-static AVR ↔ ngspice co-simulation
│ └── index.js # Re-exports for Pipeline A
├── fixtures/
│ └── blink.hex # Copied from frontend/src/__tests__/fixtures/
├── test/ # 14 test files, 47 tests total
├── plan/ # 8 markdown planning docs
└── autosearch/ # 4 findings docs
Pipeline A — Hand-rolled MNA
Goal: a transparent, minimal-dependency baseline that we fully control. Useful for:
- Understanding every step of the solver
- Cross-checking ngspice results on toy problems
- Providing a fallback if
eecircuit-enginecannot be loaded in a constrained environment
Execution path:
- User constructs a
Circuitobject and addsComponentinstances. circuit.solveDC({maxIter, tol})is called.- Solver identifies voltage-source components and allocates extra MNA rows.
- For each Newton iteration:
- Zero the G matrix and b vector (size
N + M). - Add a small shunt conductance
GMIN = 1e-12on every node for stability. - Call
component.stampDC(G, b, ctx)on every component. - Solve
G · x = bvia Gaussian elimination. - Apply damping (≤ 0.5 V step per node) and iterate until
max |Δx| < tolormaxIterreached.
- Zero the G matrix and b vector (size
- Store
nodeVoltagesandbranchCurrentsinstate.
Transient execution path:
circuit.runTransient(tEnd, dt, sampleEvery)seedsstate.prev.nodeVoltagesfrom each capacitor'sVinit(the initial condition).- On every step,
state.previs the solution from the previous step. - The capacitor stamp uses backward Euler: conductance
G_c = C/Δt, companion currentI_eq = (C/Δt)·V_prev. - Solver runs
solveDC({dt})with the capacitor stamps usingctx.prev.
See circuit-emulation-mna-solver.md for stamp-level detail.
Pipeline B — ngspice-WASM
Goal: leverage the full, battle-tested ngspice engine (30+ years of development) without reinventing any physics.
Execution path:
- First call to
getEngine()orrunNetlist()bootseecircuit-engine— downloads and instantiates ~39 MB of WASM+glue; takes ~400 ms. - Subsequent calls reuse the same
Simulationsingleton. - Each call:
- Compose a full SPICE netlist string (component cards +
.op/.tran/.ac/.dc+.end). sim.setNetList(netlist).await sim.runSim()→ returns aResultTypewithvariableNames[]anddata[].values[].
- Compose a full SPICE netlist string (component cards +
- Our wrapper exposes helpers:
vec(name)→ array of numbers (real data) or[{real, img}](complex data from.ac).dcValue(name)→ first value of the vector.findVar(name)→ index invariableNames[], matching either"v(node)"or just"node".
See circuit-emulation-ngspice.md for netlist authoring conventions and gotchas.
Mixed-signal co-simulation
avr8js runs the MCU at ~1 MHz (in JS it's slower than real 16 MHz silicon, but cycle-accurate). ngspice runs transient analysis on the analog network. They are not locked cycle-by-cycle; instead the bridge operates quasi-statically in time slices.
Per slice (default: 1 ms of AVR time = 16 000 cycles):
┌─ slice n ────────────────────────────────────────────────┐
│ │
│ 1. avr.runCycles(16_000) │
│ → new pin states, new PWM duty cycles │
│ │
│ 2. snapshot = { pin6:{type:'pwm',duty:0.5}, │
│ pin13:{type:'digital',v:5}, ... } │
│ │
│ 3. buildNetlist(snapshot) → string │
│ V_PIN6 pin6 0 DC 2.5 ← duty × 5 │
│ V_PIN13 pin13 0 DC 5 │
│ ... passive circuit ... │
│ .tran 10u 1m │
│ .end │
│ │
│ 4. await runNetlist(netlist) → result │
│ │
│ 5. for each analog channel: inject final v(node) into │
│ avr.setAnalogVoltage(ch, v) │
│ │
└──────────────────────────────────────────────────────────┘
This is good enough for:
- ADC sampling of slow analog sources (NTC, pot, photoresistor)
- PWM filtered to DC via RC
- LED current driven by a digital or PWM pin
- Digital outputs driving logic or switches
It is not good enough for:
- Audio-rate feedback loops where the analog network reacts on sub-millisecond timescales
- Oscillators where the analog side drives a digital input and vice versa with tight timing
- Noise / jitter analysis
For the tight-coupling cases, a co-simulation framework would need to arbitrate time advancement between the two engines (see Velxio Integration for future work).
Shared Arduino harness
Both pipelines use AVRHarness to drive avr8js. This class mirrors exactly what Velxio's frontend/src/simulation/AVRSimulator.ts does:
new CPU(programUint16, sramBytes)new AVRIOPort(cpu, portBConfig/portCConfig/portDConfig)new AVRADC(cpu, adcConfig)—adc.channelValues[channel] = voltsto inject analognew AVRTimer(cpu, timer0Config/...)new AVRUSART(cpu, usart0Config, 16_000_000)- Listeners per port:
port.addListener((newValue, oldValue) => ...)with bit-by-bit diff and Arduino pin mapping (D0–D7, D8–D13, A0–A5 via port C). - PWM duty read via
cpu.data[ocrAddress]where ocrAddress for Timer0A is0x47, Timer1AL is0x88, etc.
This parity is important: any lesson learned in the sandbox transfers one-for-one to Velxio's main app.
See circuit-emulation-avr-bridge.md for the full harness reference.
Fixtures and hand-assembled programs
To exercise the full sketch path (C++ → avr-gcc → hex) we would normally compile Arduino code. The sandbox does not have avr-gcc installed, so we use:
-
Copied from Velxio:
fixtures/blink.hex— the same Intel HEX used byfrontend/src/__tests__/fixtures/avr-blink/avr-blink.ino.hex. Tests that load this file exercise the full Arduino core init (reset vectors, library init,setup(),loop()). -
Hand-assembled programs:
src/avr/programs.jsexposespotToPwmProgram()andadcReadProgram(), built with the mini-assembler insrc/avr/asm.js. These bypass the Arduino core and directly configure ADC and Timer0 registers. They produce ~18–22 instruction words each. Full opcode breakdown is in circuit-emulation-appendix.md.