# 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: ```c 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 `0`s, 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`](../../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: ```ts // 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. ```ts // 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. ```ts // 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` 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: ```ts 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: ```ts 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: ```ts 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 | | --- | --- | | `` overlay | ✅ Reads `timeWaveforms.nodes[net]` and displays RMS / pk / DC (see Phase 2b) | | `` overlay | ✅ Reads `timeWaveforms.branches[src]` and displays RMS / pk / DC (see Phase 2c) | | `` | ✅ 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 dots** — `I²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`](../../frontend/src/simulation/spice/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 `analogRead`s 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).