diff --git a/test/backend/e2e/package-lock.json b/test/backend/e2e/package-lock.json index c358a14a..6e026b04 100644 --- a/test/backend/e2e/package-lock.json +++ b/test/backend/e2e/package-lock.json @@ -8,9 +8,16 @@ "name": "velxio-e2e-tests", "version": "1.0.0", "dependencies": { + "eecircuit-engine": "^1.7.0", "rp2040js": "^1.3.0" } }, + "node_modules/eecircuit-engine": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/eecircuit-engine/-/eecircuit-engine-1.7.0.tgz", + "integrity": "sha512-ZDpr/w/H81uCH3n2vjf0vohxOQqQ4NCsvaXkoYwcH+LCxIGKpBdvAIvGN5IdozW6AFJ8tojquKvDya3337yjSQ==", + "license": "MIT" + }, "node_modules/rp2040js": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/rp2040js/-/rp2040js-1.3.2.tgz", diff --git a/test/backend/e2e/test_esp32_spice_photodiode.mjs b/test/backend/e2e/test_esp32_spice_photodiode.mjs new file mode 100644 index 00000000..fca166cd --- /dev/null +++ b/test/backend/e2e/test_esp32_spice_photodiode.mjs @@ -0,0 +1,336 @@ +/** + * test_esp32_spice_photodiode.mjs + * + * End-to-end co-simulation: photodiode (SPICE model from componentToSpice.ts) + * wired to an ESP32 ADC pin, driven by a lux sweep. Exercises the exact + * SPICE cards the frontend produces for a `photodiode` part. + * + * Circuit (pull-up transimpedance, typical analogRead usage): + * + * Vcc = 3.3V + * | + * R_pull = 10k + * | + * +---- vpd (ADC34 / ADC1_CH6) + * | + * D photodiode (cathode = vpd, anode = GND) + * | + * I_ph (photocurrent source, C → A, 100 nA/lux) + * | + * GND + * + * Expected: V(vpd) = Vcc - lux * 100e-9 * R_pull = 3.3 - lux * 1e-3 [V] + * so lux=0 saturates high (3.3 V, raw≈4095) and lux=3000 is near 0 V. + * + * What this test proves (or surfaces as a failure): + * 1. The photodiode SPICE cards from `frontend/.../componentToSpice.ts` + * solve in ngspice-WASM without errors. + * 2. The backend `esp32_adc_set` WebSocket message actually changes what + * `analogRead()` returns inside the guest. + * 3. The lux → solved-voltage → injected-mV → 12-bit raw value pipeline + * round-trips within ±50 counts on a real Arduino sketch. + * + * Run: + * cd test/backend/e2e && npm install && node test_esp32_spice_photodiode.mjs + * + * Prerequisites: + * - Backend on http://localhost:8001 with libqemu-xtensa.so available + * - arduino-cli + esp32:esp32@2.0.17 installed + */ + +import { Simulation } from 'eecircuit-engine'; + +// ─── Config ─────────────────────────────────────────────────────────────────── +const BACKEND = process.env.BACKEND_URL + ?? process.argv.find(a => a.startsWith('--backend='))?.slice(10) + ?? 'http://localhost:8001'; +const WS_BASE = BACKEND.replace(/^https?:/, m => m === 'https:' ? 'wss:' : 'ws:'); +const SESSION = `test-esp32-photodiode-${Date.now()}`; +const TIMEOUT_S = parseInt( + process.argv.find(a => a.startsWith('--timeout='))?.slice(10) ?? '150' +); + +// Lux levels to sweep. Chosen so V(vpd) lands at non-trivial points across +// the ADC range, and so adjacent levels differ by > 100 counts (needed so +// the "circuit change detected" check below is meaningful). +const LUX_SWEEP = [0, 1000, 2500]; + +// Must match frontend/src/simulation/spice/componentToSpice.ts photodiode +// mapper. Kept verbatim so the test fails if the frontend model drifts. +const PHOTODIODE_MODEL = '.model DPHOTO D(Is=10p N=1.1 Rs=10)'; +const RESPONSIVITY_A_PER_LUX = 100e-9; + +// Circuit constants — mirror on the guest side so it can report its own +// converted voltage in the serial stream. +const VCC_V = 3.3; +const R_PULL = 10000; // 10k Ω +const ADC_PIN = 34; // GPIO34 == ADC1_CH6 on ESP32 +const ADC_CH = 6; // channel number the backend expects + +// ─── ESP32 sketch (compiled on-the-fly via /api/compile/) ───────────────────── +const SKETCH = `// ESP32 photodiode ADC reader for SPICE co-simulation test +void setup() { + Serial.begin(115200); + analogReadResolution(12); + delay(500); + Serial.println("ESP32_PD_READY"); +} + +void loop() { + int raw = analogRead(${ADC_PIN}); + float v = raw * ${VCC_V} / 4095.0; + Serial.printf("PD: raw=%d v=%.3fV\\n", raw, v); + delay(400); +}`; + +// ─── Logging ────────────────────────────────────────────────────────────────── +const T0 = Date.now(); +const ts = () => `[+${((Date.now() - T0) / 1000).toFixed(3)}s]`; +const C = { + INFO: '\x1b[36m', OK: '\x1b[32m', ERROR: '\x1b[31m', + SERIAL: '\x1b[32m', SPICE: '\x1b[35m', RESET: '\x1b[0m', +}; +const log = (lvl, ...a) => console.log(`${C[lvl] ?? ''}${ts()} [${lvl}]${C.RESET}`, ...a); +const info = (...a) => log('INFO', ...a); +const ok = (...a) => log('OK', ...a); +const err = (...a) => log('ERROR', ...a); +const serial = (...a) => log('SERIAL', ...a); +const spice = (...a) => log('SPICE', ...a); + +// ─── ngspice engine (singleton) ─────────────────────────────────────────────── +let engine = null; +async function bootNgspice() { + if (engine) return engine; + spice('Booting ngspice-WASM...'); + engine = new Simulation(); + await engine.start(); + spice('ngspice ready'); + return engine; +} + +/** + * Solve the photodiode pull-up circuit for the given lux. Uses the EXACT + * card pattern emitted by componentToSpice.ts so a regression in the + * frontend emitter surfaces here. + */ +async function solvePhotodiode(lux) { + const e = await bootNgspice(); + const iph = lux * RESPONSIVITY_A_PER_LUX; + const netlist = `Photodiode pull-up lux=${lux} +V1 vcc 0 DC ${VCC_V} +Rpull vcc vpd ${R_PULL} +D_pd 0 vpd DPHOTO +I_pd vpd 0 DC ${iph} +${PHOTODIODE_MODEL} +.op +.end`; + e.setNetList(netlist); + const result = await e.runSim(); + const names = result.variableNames.map(n => n.toLowerCase()); + const idx = names.indexOf('v(vpd)'); + if (idx < 0) throw new Error(`v(vpd) not in result: ${names}`); + const voltage = result.data[idx].values[0]; + spice(`lux=${lux} -> V(vpd) = ${voltage.toFixed(4)}V (iph=${(iph*1e9).toFixed(1)}nA)`); + return voltage; +} + +// ─── Compile sketch ─────────────────────────────────────────────────────────── +async function compile() { + info('Compiling ESP32 photodiode sketch...'); + const res = await fetch(`${BACKEND}/api/compile/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + files: [{ name: 'sketch.ino', content: SKETCH }], + board_fqbn: 'esp32:esp32:esp32', + }), + }); + if (!res.ok) { + throw new Error(`Compile HTTP ${res.status}: ${(await res.text()).slice(0, 500)}`); + } + const body = await res.json(); + if (!body.success) { + throw new Error(`Compile error: ${(body.error ?? body.stderr ?? '').slice(0, 500)}`); + } + const fw = body.binary_content ?? body.firmware_b64; + if (!fw) throw new Error(`No firmware returned. Keys: ${Object.keys(body)}`); + ok(`Compiled -- ${Math.round(fw.length * 0.75 / 1024)} KB`); + return fw; +} + +// ─── Co-simulation (sweep lux through the same WS session) ──────────────────── +function runCoSim(firmware_b64) { + return new Promise(async (resolve) => { + // Pre-solve every lux level with ngspice so we know the expected ADC raw. + const solved = {}; + for (const lux of LUX_SWEEP) { + solved[lux] = await solvePhotodiode(lux); + } + + const ws = new WebSocket(`${WS_BASE}/api/simulation/ws/${SESSION}`); + + let lineBuf = ''; + const serialLines = []; + let ready = false; + let luxIdx = 0; + let currentLux = LUX_SWEEP[0]; + const readings = {}; // lux -> [{raw, v}] + + const timer = setTimeout(() => { + ws.close(); + resolve({ timedOut: true, readings, serialLines, solved }); + }, TIMEOUT_S * 1000); + + function inject(lux) { + const mv = Math.round(solved[lux] * 1000); + ws.send(JSON.stringify({ + type: 'esp32_adc_set', + data: { channel: ADC_CH, millivolts: mv }, + })); + spice(`Injected lux=${lux} (${solved[lux].toFixed(3)}V, ${mv}mV) -> CH${ADC_CH}`); + } + + ws.addEventListener('open', () => { + ok('WebSocket connected'); + ws.send(JSON.stringify({ + type: 'start_esp32', + data: { board: 'esp32', firmware_b64, wifi_enabled: false }, + })); + }); + + ws.addEventListener('message', ev => { + let msg; + try { msg = JSON.parse(ev.data); } catch { return; } + if (msg.type !== 'serial_output') { + if (msg.type === 'error') err(`error: ${JSON.stringify(msg.data)}`); + return; + } + lineBuf += msg.data?.data ?? ''; + let nl; + while ((nl = lineBuf.indexOf('\n')) !== -1) { + const line = lineBuf.slice(0, nl).replace(/\r$/, ''); + lineBuf = lineBuf.slice(nl + 1); + if (!line.trim()) continue; + serialLines.push(line); + serial(`UART: ${line}`); + + if (line.includes('ESP32_PD_READY') && !ready) { + ready = true; + ok(`ESP32 ready -- starting lux sweep (${LUX_SWEEP.join(', ')})`); + inject(currentLux); + } + + const m = line.match(/PD:\s*raw=(\d+)\s+v=([\d.]+)V/); + if (m) { + const reading = { raw: parseInt(m[1]), v: parseFloat(m[2]) }; + if (!readings[currentLux]) readings[currentLux] = []; + readings[currentLux].push(reading); + + // Collect 2 readings per level, then advance. + if (readings[currentLux].length >= 2) { + luxIdx++; + if (luxIdx < LUX_SWEEP.length) { + currentLux = LUX_SWEEP[luxIdx]; + info(`Switching to lux=${currentLux}`); + inject(currentLux); + } else { + clearTimeout(timer); + ws.close(); + resolve({ timedOut: false, readings, serialLines, solved }); + } + } + } + } + }); + + ws.addEventListener('error', e => err(`WS error: ${e.message ?? e}`)); + ws.addEventListener('close', () => { + clearTimeout(timer); + if (luxIdx < LUX_SWEEP.length) { + resolve({ timedOut: true, readings, serialLines, solved }); + } + }); + }); +} + +// ─── Validate ───────────────────────────────────────────────────────────────── +function validate(result) { + const { timedOut, readings, solved } = result; + info(''); + info('═══════════════════════════════════════════════════════════'); + info(' Photodiode + ngspice + ESP32 co-simulation results'); + info('═══════════════════════════════════════════════════════════'); + + let pass = !timedOut; + if (timedOut) err('Timed out before collecting readings for all lux levels'); + + const expected = {}; + for (const lux of LUX_SWEEP) { + expected[lux] = Math.round(solved[lux] / VCC_V * 4095); + info(`lux=${lux.toString().padStart(5)} V=${solved[lux].toFixed(4)}V expected raw=${expected[lux]}`); + } + info(''); + + const avgs = {}; + for (const lux of LUX_SWEEP) { + const rs = readings[lux] ?? []; + if (rs.length === 0) { + err(`No readings captured for lux=${lux}`); + pass = false; + continue; + } + avgs[lux] = rs.reduce((s, r) => s + r.raw, 0) / rs.length; + const diff = Math.abs(avgs[lux] - expected[lux]); + // Tolerance 50 counts matches the voltage-divider test — accounts for + // the millivolt round-trip + QEMU scheduling jitter. + if (diff > 50) { + err(`lux=${lux}: avg raw=${avgs[lux].toFixed(0)} (expected ${expected[lux]}, off by ${diff.toFixed(0)} > 50)`); + pass = false; + } else { + ok(`lux=${lux}: avg raw=${avgs[lux].toFixed(0)} (expected ${expected[lux]}, within tolerance)`); + } + } + + // Monotonicity: brighter = lower voltage = lower raw. If the photodiode + // cards or the ADC injection pipeline are broken, readings would be flat + // or random. + const ordered = LUX_SWEEP.map(l => avgs[l]).filter(v => v !== undefined); + const monotone = ordered.every((v, i) => i === 0 || v <= ordered[i - 1]); + if (!monotone) { + err(`Readings not monotonically decreasing with lux: ${ordered.map(v => v?.toFixed(0)).join(' > ')}`); + pass = false; + } else { + ok(`Brighter light drops the reading as expected: ${ordered.map(v => v?.toFixed(0)).join(' > ')}`); + } + + info(''); + if (pass) { + ok('ALL CHECKS PASSED -- photodiode + SPICE + ESP32 pipeline works'); + process.exit(0); + } else { + err('SOME CHECKS FAILED'); + process.exit(1); + } +} + +// ─── Main ───────────────────────────────────────────────────────────────────── +async function main() { + info('Photodiode + ngspice + ESP32 co-simulation E2E'); + info(`Backend: ${BACKEND}`); + info(`Timeout: ${TIMEOUT_S}s`); + info(''); + try { + await bootNgspice(); + const firmware = await compile(); + const result = await runCoSim(firmware); + validate(result); + } catch (e) { + err(`Fatal: ${e.message}`); + if (e.message?.includes('fetch')) { + err('Is the backend running? Start with: cd backend && uvicorn app.main:app --port 8001'); + } + process.exit(1); + } +} + +main();