velxio/docs/wiki/circuit-emulation-avr-bridg...

336 lines
12 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
# AVR Integration & Mixed-Signal Bridge
Location: [`test/test_circuit/src/avr/`](../../test/test_circuit/src/avr/), [`test/test_circuit/src/spice/AVRSpiceBridge.js`](../../test/test_circuit/src/spice/AVRSpiceBridge.js)
## Mirroring Velxio's `AVRSimulator.ts`
The sandbox harness is a deliberately faithful reproduction of what Velxio already does in `frontend/src/simulation/AVRSimulator.ts`:
```typescript
// Velxio (trimmed)
this.cpu = new CPU(programWords, 8192);
this.portB = new AVRIOPort(this.cpu, portBConfig);
this.portC = new AVRIOPort(this.cpu, portCConfig);
this.portD = new AVRIOPort(this.cpu, portDConfig);
this.adc = new AVRADC(this.cpu, adcConfig);
this.peripherals = [
new AVRTimer(this.cpu, timer0Config),
new AVRTimer(this.cpu, timer1Config),
new AVRTimer(this.cpu, timer2Config),
new AVRUSART(this.cpu, usart0Config, 16_000_000),
new AVRSPI(this.cpu, spiConfig, 16_000_000),
new AVRTWI(this.cpu, twiConfig, 16_000_000),
];
// Execution loop:
avrInstruction(this.cpu);
this.cpu.tick();
```
```javascript
// Sandbox: test/test_circuit/src/avr/AVRHarness.js
this.cpu = new CPU(program, 8192);
this.ports.B = new AVRIOPort(this.cpu, portBConfig);
this.ports.C = new AVRIOPort(this.cpu, portCConfig);
this.ports.D = new AVRIOPort(this.cpu, portDConfig);
this.adc = new AVRADC(this.cpu, adcConfig);
this.timers = [
new AVRTimer(this.cpu, timer0Config),
new AVRTimer(this.cpu, timer1Config),
new AVRTimer(this.cpu, timer2Config),
];
this.usart = new AVRUSART(this.cpu, usart0Config, 16_000_000);
// Execution loop:
avrInstruction(this.cpu);
this.cpu.tick();
```
If it works in the sandbox, it works in Velxio. Confirmed with `fixtures/blink.hex` which is a byte-for-byte copy of `frontend/src/__tests__/fixtures/avr-blink/avr-blink.ino.hex`.
## Intel HEX parser
Velxio uses `utils/hexParser.ts`. The sandbox reimplements the same Intel HEX format from scratch in [`src/avr/intelHex.js`](../../test/test_circuit/src/avr/intelHex.js):
```javascript
export function parseIntelHex(text) {
const bytes = [];
let highAddr = 0;
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line.startsWith(':')) continue;
const byteCount = parseInt(line.slice(1, 3), 16);
const addr = parseInt(line.slice(3, 7), 16);
const type = parseInt(line.slice(7, 9), 16);
if (type === 0) {
const fullAddr = (highAddr << 16) | addr;
for (let i = 0; i < byteCount; i++) {
bytes[fullAddr + i] = parseInt(line.slice(9 + i*2, 11 + i*2), 16);
}
} else if (type === 1) break; // EOF
else if (type === 4) highAddr = parseInt(line.slice(9, 13), 16);
}
return new Uint8Array(bytes);
}
export function bytesToProgramWords(bytes, wordCount = 0x8000 / 2) {
const prog = new Uint16Array(wordCount);
for (let i = 0; i < bytes.length; i += 2) {
prog[i >> 1] = (bytes[i] || 0) | ((bytes[i + 1] || 0) << 8);
}
return prog;
}
```
Handles record types:
- `00` — data (the bulk)
- `01` — EOF
- `04` — extended linear address (for programs > 64 KB; not needed for ATmega328P's 32 KB flash but included for future-proofing)
## AVRHarness API
[`src/avr/AVRHarness.js`](../../test/test_circuit/src/avr/AVRHarness.js)
```javascript
const avr = new AVRHarness();
// Load program (two ways)
avr.load(hexText); // Intel HEX string
avr.loadProgram(uint16ArrayOfWords); // pre-assembled
// Execute
avr.runCycles(16_000_000); // 1 second at 16 MHz
// Read pin state
avr.getPin(13); // 0 or 1 (D13 = PORTB bit 5)
avr.getPin(6); // D6 = PORTD bit 6
avr.getPin(14); // A0 as digital = PORTC bit 0
// Register for pin changes
const unsub = avr.onPinChange(13, (state) => console.log('D13 now', state));
// Inject analog voltage (0..5V) on ADC channel 0..5
avr.setAnalogVoltage(0, 2.5); // A0 = 2.5V
// Read PWM duty from OCR register (0..1)
avr.getPWMDuty(6); // D6 → Timer0A → OCR0A at 0x47
avr.getPWMDuty(9); // D9 → Timer1A → OCR1AL at 0x88
// Raw CPU access (for testing / debugging)
avr.cpu.data[0x79]; // ADCH register
avr.cpu.data[0x88]; // OCR1AL register
avr.cpu.cycles; // total executed cycles
// USART TX (serial output)
avr.getSerialOutput(); // string of all bytes transmitted so far
```
### Pin-to-port mapping (Arduino Uno convention)
| Arduino pin | Port | Bit | Used for |
|---|---|---|---|
| D0 | PORTD | 0 | RX |
| D1 | PORTD | 1 | TX |
| D2 | PORTD | 2 | Interrupt 0 |
| D3 | PORTD | 3 | Timer2B PWM |
| D4 | PORTD | 4 | |
| D5 | PORTD | 5 | Timer0B PWM |
| D6 | PORTD | 6 | Timer0A PWM |
| D7 | PORTD | 7 | |
| D8 | PORTB | 0 | |
| D9 | PORTB | 1 | Timer1A PWM (16-bit!) |
| D10 | PORTB | 2 | Timer1B PWM |
| D11 | PORTB | 3 | Timer2A PWM |
| D12 | PORTB | 4 | |
| D13 | PORTB | 5 | LED_BUILTIN |
| A0 | PORTC | 0 | ADC ch 0 |
| A1 | PORTC | 1 | ADC ch 1 |
| A2 | PORTC | 2 | ADC ch 2 |
| A3 | PORTC | 3 | ADC ch 3 |
| A4 | PORTC | 4 | ADC ch 4 / SDA |
| A5 | PORTC | 5 | ADC ch 5 / SCL |
### PWM OCR register addresses (ATmega328P)
| Pin | Timer | Register | Address |
|---|---|---|---|
| D3 | Timer2B | OCR2B | 0xB4 |
| D5 | Timer0B | OCR0B | 0x48 |
| D6 | Timer0A | OCR0A | 0x47 |
| D9 | Timer1A | OCR1AL (low byte of 16-bit) | 0x88 |
| D10 | Timer1B | OCR1BL | 0x8A |
| D11 | Timer2A | OCR2A | 0xB3 |
Our harness reads the low byte and divides by 255 to estimate duty. For Timer1 this is valid only when the timer is configured for 8-bit PWM mode (which our `potToPwmProgram` does not use — it uses Timer0 instead via OCR0A).
### ADC register model
`avr8js`'s `AVRADC` exposes `channelValues: number[]` (one slot per channel). Writing a value in **volts** (0..5) injects it; the ADC performs the 10-bit quantization automatically on the next `analogRead()`.
To read the result directly without writing a sketch that stores it to a register, you can also read `cpu.data[0x78]` (ADCL) and `cpu.data[0x79]` (ADCH).
**Right-adjusted (default, ADLAR=0)**:
```
ADCH = 0b000000xx ; top 2 bits of 10-bit result
ADCL = 0bxxxxxxxx ; bottom 8 bits
result = (ADCH << 8) | ADCL;
```
**Left-adjusted (ADLAR=1)** — useful if you only want to read ADCH:
```
ADCH = 0bxxxxxxxx ; top 8 bits
ADCL = 0bxx000000 ; bottom 2 bits
result = (ADCH << 2) | (ADCL >> 6);
```
**Gotcha we hit**: the `potToPwmProgram` sketch initially read only ADCH and wrote it to OCR0A. With ADLAR=0 this only gave the top 2 bits (0..3) — duty was stuck at 01 %. Changing ADMUX from `0x40` to `0x60` (enable ADLAR) fixed it.
## Hand-assembled Arduino programs
[`src/avr/asm.js`](../../test/test_circuit/src/avr/asm.js) exposes a mini-assembler covering the opcodes we need.
### Supported opcodes
```javascript
LDI(rd, k) // Load immediate, rd ∈ [16,31], k ∈ [0,255]
OUT(A, rr) // Out to I/O, A ∈ [0,63]
IN(rd, A) // In from I/O
STS(k, rr) // Store to data space (32-bit instruction)
LDS(rd, k) // Load from data space (32-bit)
RJMP(offset) // Relative jump (12-bit signed word offset)
SBRC(rr, b) // Skip if bit in register clear
SBRS(rr, b) // Skip if bit in register set
NOP() // No operation
// Assemble a list (numbers = 1 word, arrays = 2 words)
assemble([ LDI(16, 0x40), OUT(0x0A, 16), RJMP(-1) ])
→ Uint16Array [0xE400, 0xB90A, 0xCFFF]
```
### Encoding reference (ATmega AVR instruction set)
| Opcode | Encoding |
|---|---|
| `LDI Rd, K` | `1110 KKKK dddd KKKK``d = Rd 16` |
| `OUT A, Rr` | `1011 1AAr rrrr AAAA` |
| `IN Rd, A` | `1011 0AAd dddd AAAA` |
| `STS k, Rr` | `1001 001r rrrr 0000` + 16-bit `k` |
| `LDS Rd, k` | `1001 000d dddd 0000` + 16-bit `k` |
| `RJMP k` | `1100 kkkk kkkk kkkk` (signed 12-bit offset from PC+1) |
| `SBRC Rr, b` | `1111 110r rrrr 0bbb` |
| `SBRS Rr, b` | `1111 111r rrrr 0bbb` |
| `NOP` | `0000 0000 0000 0000` |
### The two test programs
#### `potToPwmProgram()`
Equivalent Arduino sketch:
```c
void setup() {
pinMode(6, OUTPUT);
}
void loop() {
int v = analogRead(A0); // 10-bit
analogWrite(6, v >> 2); // map to 8-bit PWM on D6
}
```
Actual implementation:
1. Set DDRD bit 6 → pin 6 as output
2. Configure Timer0 for Fast PWM 8-bit, non-inverting on OC0A (D6)
3. ADMUX = 0x60 → AVCC reference, ADLAR=1 (left-adjust), channel 0
4. ADCSRA = 0x87 → ADC enable + prescaler 128 (ADC clock = 125 kHz)
5. Loop:
- Write ADSC bit to start conversion
- Busy-wait until ADSC clears
- Read ADCH (top 8 bits of left-adjusted result)
- Write to OCR0A (PWM duty)
22 words (44 bytes).
#### `adcReadProgram()`
Simpler variant used by the thermistor test: reads ADC repeatedly and stores the raw bytes into registers `r20` (ADCH) and `r21` (ADCL). The host test then reads them from `cpu.data[r20_addr]` or reconstructs the 10-bit result from `(ADCH << 2) | (ADCL >> 6)`.
## AVRSpiceBridge — the co-simulation layer
[`src/spice/AVRSpiceBridge.js`](../../test/test_circuit/src/spice/AVRSpiceBridge.js)
### Constructor
```javascript
const bridge = new AVRSpiceBridge(avr, {
sliceMs: 1, // AVR runs in 1 ms slices between ngspice solves
analogChannels: [ // which ngspice nodes feed which ADC channels
{ channel: 0, node: 'a0' },
{ channel: 1, node: 'a1' },
],
});
```
### Runtime
```javascript
await bridge.run(totalMs, (pinSnapshots, sliceStartMs, sliceEndMs) => {
// Return a full ngspice netlist string.
// pinSnapshots[6] = { type: 'pwm', duty: 0.5 } (only if duty > 0)
// or { type: 'digital', v: 0 | 5 }
return `My circuit
V_PIN6 pin6 0 DC ${pinSnapshots[6].type === 'pwm' ? pinSnapshots[6].duty * 5 : pinSnapshots[6].v}
R1 pin6 out 10k
C1 out 0 1u IC=0
.tran 10u 1m
.end`;
});
```
### Algorithm, step by step
```
for slice in slices:
# 1. Run the AVR for this slice
avr.runCycles(16_000_000 * sliceMs / 1000)
# 2. Snapshot pin states
snapshot = {}
for pin in 0..13:
duty = avr.getPWMDuty(pin)
if duty is not null and duty > 0:
snapshot[pin] = { type: 'pwm', duty }
else:
snapshot[pin] = { type: 'digital', v: avr.getPin(pin) * 5 }
# 3. Build netlist
netlist = buildNetlist(snapshot, t0, t1)
# 4. Solve it
result = await runNetlist(netlist)
# 5. Inject voltages back into ADC channels
for { channel, node } in analogChannels:
v = result.vec(f'v({node})')
v_end = v[-1] # last time point
avr.setAnalogVoltage(channel, v_end)
adcSamples.push({ t: t1/1000, channel, node, v: v_end })
```
### Design choices
- **Slice-based**: PWM duty and digital levels are treated as constant within one slice. Works because our analog circuits have time constants (RC filters, ADC sample-and-hold) that are an order of magnitude slower than the slice length.
- **PWM → DC-equivalent**: we convert PWM to its duty-averaged DC voltage and hand that to ngspice as a constant source. If you need to study the PWM ripple itself, you would instead emit a `PULSE()` source — at the cost of ngspice having to take sub-microsecond timesteps.
- **Chicken-and-egg at slice 0**: the first slice runs the AVR before ngspice has computed any voltage. The AVR therefore starts with `channelValues[ch] = 0`. By slice 2 the ADC sees the real voltage. In practice, for tests we run several slices to let the system settle; in production UIs this is unnoticeable.
### Limitations
- **Not cycle-accurate**. Tight feedback loops (an analog oscillator whose output drives an MCU interrupt input with microsecond-tight requirements) cannot be expressed.
- **No back-annotation of MCU GPIO from SPICE**. We inject ADC voltages but we don't let an analog node drive a digital input pin with logic-level thresholds. Supported in principle — you'd read the SPICE result and call `avr.ports.X.setPin(bit, value)` — but the harness doesn't expose that today.
### Showcase test
`test/spice_avr_mixed.test.js` runs three co-simulated scenarios:
1. **NTC → ngspice → ADC → sketch**. At 0/25/50 °C, ngspice solves the NTC+pullup divider, we feed the result into `AVRHarness.setAnalogVoltage(0, v)`, run the `adcReadProgram`, and verify the register content matches the expected ADC code within ±2 LSB.
2. **Sketch → PWM → ngspice RC → DC**. The `potToPwmProgram` sketch computes a PWM duty from a simulated pot voltage; the DC-equivalent of the PWM is fed to an RC filter in ngspice; the settled voltage matches `duty × 5 V` within 100 mV.
3. **Full bridge loop — pot wiper move**. The bridge runs 10 slices (5 ms total). Wiper at 0.25 → ADC reads 256. Wiper moves to 0.75 → ADC reads 768. Monotonic, ±5 LSB accuracy.