velxio/docs/wiki/circuit-emulation-adc-alias...

14 KiB
Raw Blame History

Time-Domain Fidelity: The ADC Aliasing Bug

A deep-dive into why analogRead(A0) returned 0 on the Half-Wave Rectifier example even though the SPICE solver was producing a perfectly correct rectified waveform — and how we fixed it with a per-read hook into the emulated ADC peripheral.

Target audience

Anyone touching the electrical-simulation layer in Velxio, or trying to make an Arduino/RP2040 sketch sample an AC source with real-world fidelity.


1. The symptom

The built-in example Half-Wave Rectifier wires:

signal-generator(sine, 50 Hz, 5 Vpp) → 1N4007 → 10 kΩ load ─┬─ A0
                                                            └─ GND

The sketch does:

void setup()  { Serial.begin(9600); }
void loop()   { Serial.println(analogRead(A0)); delay(5); }

Expected: values oscillating between 0 (negative half-cycle blocked by the diode) and ~880 (positive peak ≈ 4.3 V → 4.3/5 × 1024).

Actual (pre-fix): a long run of 0s, indefinitely.


2. The architecture (refresher)

┌────────────────────┐  SPICE  ┌─────────────────┐  RAF (60 Hz)  ┌─────────┐
│ NetlistBuilder     │ ──────► │ .tran waveform  │ ────────────► │ AVRADC  │ ───┐
│ (.op vs .tran)     │         │ (400 samples)   │   sets        │ channel │    │
└────────────────────┘         └─────────────────┘  channelValues│ Values  │    │
                                                                 └─────────┘    │
                                                                                ▼
                                                                ┌───────────────────────┐
                                                    analogRead  │ AVR CPU (avr8js)       │
                                                    (200 Hz)    │ reads channelValues[]  │
                                                                └───────────────────────┘

The key file is frontend/src/simulation/spice/subscribeToStore.ts. It runs the RAF loop and writes channelValues[channel] = v at each frame.


3. Root cause, layer 1 — the clock was frozen at t = 0

The first adcReplayFrame() log line showed t: 0, v: -0, clamped: 0 for hundreds of frames. Reason:

// BROKEN:
function simTimeSeconds(sim): number {
  return sim.getCurrentCycles() / 16_000_000;  // AVR cycle count
}

Before the user presses Run, cpu.cycles === 0. So t = 0 and V(sine at phase 0) = 0 — forever. Even after Run, the cycle counter is updated in batches on the same animation frame, so the 60 Hz sampler was stepping in non-monotonic jumps that aliased hard against the signal.

Fix: drive the replay clock from wall-clock time. An ideal signal generator keeps oscillating regardless of whether the MCU is running — a paused AVR still sees a live signal on its pins, just like real hardware.

// FIXED:
function simTimeSeconds(_sim): number {
  return (performance.now() - replayStartWallMs) / 1000;
}

After this fix, the browser console showed channelValues[0] oscillating correctly after the AVR stopped, but during the run it was still stuck on long flat runs of the same value.


4. Root cause, layer 2 — frame-rate aliasing

The RAF runs at ~60 Hz. The signal is 50 Hz. Beat frequency = 10 Hz. Every ~100 ms the RAF samples the sine at nearly the same phase, producing long plateaus of near-identical voltages.

But the deeper problem was this: channelValues[0] is overwritten once per animation frame, and the AVR sketch calls analogRead at ~200 Hz — meaning 3-4 consecutive reads within a single frame all see exactly the same voltage. The entire time-domain resolution offered by the 400-sample .tran waveform was being collapsed to a 60 Hz zero-order hold.

Nyquist gives the bound: to faithfully sample a 50 Hz signal, the reader must sample at > 100 Hz. 60 Hz RAF alone fails that — with the additional same-value-within-frame issue, the effective sampling rate drops to zero.


5. Why the test didn't catch this

The L9 test in spice-rectifier-live-repro.test.ts verified that the RAF loop writes correct varying values into channelValues[0] across a simulated 120 ms window. It stubbed performance.now to advance by 16 ms per frame and collected a sequence like [0, 0, 2.543, 4.336, 0, 0, ...]. That sequence was correct, and the test was happy.

But the test never called analogRead on the running AVR. It tested the write path, not the read path. The real failure was on the read side: multiple reads within one animation frame seeing the same stale channelValues[0]. This only manifests when:

  1. The AVR is actually executing (cycles advancing),
  2. At the same time as RAF is firing,
  3. And the sketch samples faster than 60 Hz.

The fix: a new integration test should patch AVRADC.onADCRead, advance the sim 200 AVR reads, and assert that the collected sequence has non-monotonic oscillations with period ≈ 20 ms (50 Hz).


6. The fix — per-read waveform sampling

Replace AVRADC.onADCRead with a version that interpolates the SPICE waveform at the exact wall-clock time of the read. Now every analogRead call, regardless of RAF rate, samples the signal at its own call rate (200 Hz for a 5 ms-delay loop) — well above Nyquist for a 50 Hz source.

// Simplified — see subscribeToStore.ts::installAdcReadHooks for the full code.
self.onADCRead = function (input) {
  const net = channelToNet.get(input.channel);
  const voltage = sampleWaveformAtNow(net) ?? self.channelValues[input.channel] ?? 0;
  const rawValue = (voltage / self.referenceVoltage) * 1024;
  const result = Math.min(Math.max(Math.floor(rawValue), 0), 1023);
  self.cpu.addClockEvent(() => self.completeADCRead(result), self.sampleCycles);
};

Two subtle correctness requirements that turned up during implementation:

  • Idempotent patching. loadHex creates a fresh AVRADC on every Compile+Run. Use a WeakSet<object> to track already-patched ADC instances, and re-run the installer from inside adcReplayFrame so it catches new ADCs between frames.
  • Fallback to channelValues. If there's no .tran waveform for that net (e.g. DC circuit), fall back to whatever the normal DC injection pipeline wrote. This keeps every existing DC example working untouched.

Serial output after the fix

143 760 937 773 353 5 0 0 0 0 0 0 0 0 0 218 588 817 903 723 438 0 0 0 656 297

Peaks ≈ 937 (= 4.58 V × 1024/5) match the expected 5 V 0.7 V diode drop. Zeros during negative half-cycles confirm the diode is blocking correctly. Intermediate samples trace the sine envelope faithfully.


7. Generalization to RP2040

The same bug class exists for any MCU whose ADC reads a SPICE net fed by an AC source. RPADC from rp2040js has a slightly different signature:

onADCRead: (channel: number) => void = (channel) => {
  this.currentChannel = channel;
  this.sampleAlarm.schedule(this.sampleTime * 1000);
};

…and reads from channelValues[currentChannel] inside the scheduled alarm callback. The hook for RP2040 is simpler — we overwrite channelValues[channel] synchronously at the moment of the read, then delegate to the original implementation:

const originalOnADCRead = self.onADCRead.bind(self);
self.onADCRead = function (channel: number) {
  const v = sampleWaveformAtNow(channelToNet.get(channel));
  if (v != null) {
    // RP2040 is 12-bit, 0-3.3V full scale.
    const clamped = Math.max(0, Math.min(3.3, v));
    self.channelValues[channel] = Math.round((clamped / 3.3) * 4095);
  }
  originalOnADCRead(channel);
};

The installer auto-detects which path to use:

const isRp2040 = 'resolution' in (adc as object);

ESP32 family — per-read fidelity via QEMU waveform injection

ESP32 / ESP32-S3 / ESP32-C3 run inside QEMU (third-party/qemu-lcgamboa), so we cannot monkey-patch a JS onADCRead function. Instead the full periodic waveform is pushed down to the QEMU SAR ADC peripheral, which interpolates it against QEMU_CLOCK_VIRTUAL on every MMIO read — giving the guest the same sub-read fidelity as the AVR/RP2040 path.

Pipeline:

frontend solve (.tran)
  └─► useElectricalStore.timeWaveforms
        └─► installAdcReadHooks() → pushEsp32Waveforms()
              └─► Esp32Bridge.setAdcWaveform(pin, u12, periodNs)
                    └─► WebSocket "esp32_adc_waveform"
                          └─► backend esp32_worker.py
                                └─► lib.qemu_picsimlab_set_apin_waveform(...)
                                      └─► QEMU SAR ADC interpolates on read

See docs/wiki/circuit-emulation-esp32-qemu.md for the QEMU-side details (rebuild instructions, symbol names, data format).


8. Where fidelity still falls short (and what to do about it)

The per-read hook brings ADC readings to hardware-faithful fidelity across AVR, RP2040, and ESP32 (QEMU). Several items from earlier drafts of this table have since been implemented:

Consumer Status
<Voltmeter/> overlay Reads timeWaveforms.nodes[net] and displays RMS / pk / DC (see Phase 2b)
<Ammeter/> overlay Reads timeWaveforms.branches[src] and displays RMS / pk / DC (see Phase 2c)
<ElectricalOverlay/> Prefixes AC wires with ~ and shows AC/DC badge (Phase 2d)
Capacitor-charging examples pickDynamicAnalysis promotes to .tran when a reactive element is wired to an MCU-driven pin (Phase 4)
ESP32 per-read ADC Waveform pushed to QEMU SAR ADC; interpolated on MMIO read (Phase 3)

Still on the backlog (out of scope for the Phase 1-6 delivery):

  • LED brightness already period-averages |I(t)| at BasicParts.ts:170; no work required there.
  • PWM-as-voltage-source fidelity — PWM is still modeled as V = duty · Vcc (quasi-static). For class-D amps or buck converters that rely on switching edges, this is still a gap.
  • ngspice state resumption / lockstep co-simulation — would let the MCU drive ngspice cycle-by-cycle rather than consuming a precomputed periodic waveform. Large refactor; not needed for the examples we ship.
  • Resistor/diode heat dotsI²R integrated over the period is easy now that timeWaveforms is available, but no UI hook has been wired yet.

9. Checklist for adding a new AC/transient example

  1. Ensure the circuit uses a signal-generator or equivalent AC source. pickDynamicAnalysis in storeAdapter.ts must detect it and switch analysis to .tran.
  2. If the ADC pin is wired to a net driven by that source, verify the sketch samples at ≥ 2 × f_signal (e.g. delay(2) for 50 Hz → 500 Hz sampling, plenty of headroom).
  3. Boards supported today for per-read fidelity: AVR (Uno, Nano, Mega, ATtiny85), RP2040 (Pico, Pico W), and the ESP32 family (ESP32, ESP32-S3, ESP32-C3) via QEMU waveform injection.
  4. Add a test in frontend/src/__tests__/ following the pattern in spice-rectifier-live-repro.test.ts::L9 — install the per-read hook, advance simulated wall-clock, and assert the captured trace has the expected periodicity. For ESP32 coverage, gate behind VELXIO_ESP32_E2E=1 (see esp32-rectifier-integration.test.ts).

10. Key files

File Role
frontend/src/simulation/spice/subscribeToStore.ts Per-read onADCRead hooks (AVR + RP2040) + pushEsp32Waveforms (QEMU)
frontend/src/simulation/spice/waveformStats.ts rms, mean, peak, peakToPeak, isAC, interpolateAt
frontend/src/simulation/spice/probes.ts Voltmeter/Ammeter readings (AC stats when timeWaveforms present)
frontend/src/simulation/spice/CircuitScheduler.ts Runs .tran and returns timeWaveforms
frontend/src/simulation/spice/storeAdapter.ts::pickDynamicAnalysis Decides .op vs .tran (AC source OR reactive + driven pin)
third-party/avr8js/src/peripherals/adc.ts AVRADC.onADCRead — patched at runtime
third-party/rp2040js/src/peripherals/adc.ts RPADC.onADCRead — patched at runtime
third-party/qemu-lcgamboa/hw/misc/esp32_sens.c ESP32 SAR ADC — LUT + interpolation
third-party/qemu-lcgamboa/hw/misc/esp32c3_saradc.c ESP32-C3 SAR ADC — LUT + interpolation
frontend/src/simulation/Esp32Bridge.ts::setAdcWaveform Base64 encoder + WebSocket transport for waveform LUTs
backend/app/services/esp32_worker.py Calls qemu_picsimlab_set_apin_waveform via ctypes
frontend/src/simulation/parts/partUtils.ts::setAdcVoltage DC-path voltage injection (fallback)
frontend/src/__tests__/spice-rectifier-live-repro.test.ts Live-flow regression test
frontend/src/__tests__/{voltmeter,ammeter}-waveform.test.ts Voltmeter/Ammeter AC-reading regression
frontend/src/__tests__/capacitor-charge-transient.test.ts RC step-response pickup regression
frontend/src/__tests__/esp32-rectifier-integration.test.ts ESP32 E2E (gated by VELXIO_ESP32_E2E=1)

11. TL;DR

  • Bug A: cycle-based clock was 0 before Run → signal frozen at phase 0. Fix: switch to performance.now().
  • Bug B: 60 Hz RAF can't drive a 50 Hz signal, and all analogReads within one frame return the same value. Fix: override AVRADC.onADCRead / RPADC.onADCRead to sample the SPICE waveform at the moment of each read.
  • Why tests missed it: tests asserted on writes to channelValues, not on values returned by analogRead. Add read-side tests going forward.
  • Where this doesn't generalize yet: PWM fidelity is still quasi-static (V = duty·Vcc) — switching edges are invisible to SPICE. ngspice state resumption / lockstep co-simulation is the next big unlock but is a weeks-long scheduler rewrite.
  • What WAS still a gap in earlier drafts and is now fixed:
    • ESP32 family — waveforms pushed to QEMU SAR ADC (Phase 3).
    • Voltmeter / Ammeter / Overlay — compute RMS / peak / DC from timeWaveforms (Phase 2).
    • MCU-driven RC / RL step response — pickDynamicAnalysis promotes to .tran automatically (Phase 4).