From 3197935461219427987111c749018f8502366d2d Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Wed, 15 Apr 2026 23:28:47 -0300 Subject: [PATCH] feat: add ESP32 + ngspice co-simulation E2E tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new end-to-end tests that combine ESP32 QEMU emulation (via backend WebSocket) with ngspice-WASM analog circuit solving: 1. test_esp32_spice_analog.mjs — voltage divider sweep - Compiles a sketch that reads analogRead(34) - Solves two voltage dividers with ngspice (R1/R2=10k/10k then 10k/30k) - Injects solved V(mid) into ESP32's ADC via esp32_adc_set - Verifies Serial output matches within +-50 counts (12-bit ADC) - Confirms circuit change is detected (different ADC values) 2. test_esp32_spice_ntc_bridge.mjs — Wheatstone bridge temperature sweep - NTC thermistor in a bridge (0C / 25C / 50C) - ngspice solves the bridge for each temperature - ESP32 reads both legs (ADC34+ADC35), computes R_ntc and T via beta model - Verifies temperature within +-5C tolerance across sweep 3. test_esp32_spice_smoke.mjs — ngspice-only smoke test (no backend needed) Also adds eecircuit-engine to test/backend/e2e/package.json. Prerequisites: backend on localhost:8001 with esp32 core installed. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/backend/e2e/package.json | 2 + test/backend/e2e/test_esp32_spice_analog.mjs | 357 ++++++++++++++++++ .../e2e/test_esp32_spice_ntc_bridge.mjs | 329 ++++++++++++++++ test/backend/e2e/test_esp32_spice_smoke.mjs | 27 ++ 4 files changed, 715 insertions(+) create mode 100644 test/backend/e2e/test_esp32_spice_analog.mjs create mode 100644 test/backend/e2e/test_esp32_spice_ntc_bridge.mjs create mode 100644 test/backend/e2e/test_esp32_spice_smoke.mjs diff --git a/test/backend/e2e/package.json b/test/backend/e2e/package.json index 37da8fb5..c692a90b 100644 --- a/test/backend/e2e/package.json +++ b/test/backend/e2e/package.json @@ -2,8 +2,10 @@ "name": "velxio-e2e-tests", "version": "1.0.0", "private": true, + "type": "module", "description": "E2E test dependencies for Velxio simulator", "dependencies": { + "eecircuit-engine": "^1.7.0", "rp2040js": "^1.3.0" } } diff --git a/test/backend/e2e/test_esp32_spice_analog.mjs b/test/backend/e2e/test_esp32_spice_analog.mjs new file mode 100644 index 00000000..b02e5fde --- /dev/null +++ b/test/backend/e2e/test_esp32_spice_analog.mjs @@ -0,0 +1,357 @@ +/** + * test_esp32_spice_analog.mjs + * + * Full end-to-end co-simulation test: ESP32 (QEMU via backend) + ngspice (WASM). + * + * What it tests: + * 1. Compile a minimal ESP32 sketch that reads analogRead(34) every 500 ms + * and prints "ADC34: raw=XXXX voltage=X.XXXV" via Serial. + * 2. Boot the ESP32 in QEMU via the backend WebSocket. + * 3. Run ngspice to solve a voltage divider circuit (R1=10k + R2=10k, Vcc=3.3V) + * producing V(mid) = 1.65 V. + * 4. Inject V(mid) into ESP32's ADC channel 6 (GPIO34) via `esp32_adc_set`. + * 5. Read Serial output and verify the ADC value matches the SPICE voltage + * within tolerance (12-bit ADC: 4096 counts over 3.3V → ±20 counts). + * 6. Update the circuit (R2=30k → V(mid)=2.475V), re-inject, and verify + * the ESP32 reads the new voltage. + * + * Run: + * cd test/backend/e2e && npm install && node test_esp32_spice_analog.mjs + * + * Prerequisites: + * - Backend running on http://localhost:8001 + * - ESP32 Arduino core installed (`arduino-cli core install esp32:esp32`) + */ + +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-spice-${Date.now()}`; +const TIMEOUT_S = parseInt( + process.argv.find(a => a.startsWith('--timeout='))?.slice(10) ?? '90' +); + +// ─── ESP32 ADC sketch ──────────────────────────────────────────────────────── +const SKETCH = `// ESP32 ADC reader for SPICE co-simulation test +// Reads GPIO34 (ADC1_CH6) at 12-bit resolution + +void setup() { + Serial.begin(115200); + analogReadResolution(12); + delay(500); + Serial.println("ESP32_ADC_READY"); +} + +void loop() { + int raw = analogRead(34); + float voltage = raw * 3.3 / 4095.0; + Serial.printf("ADC34: raw=%d voltage=%.3fV\\n", raw, voltage); + delay(500); +}`; + +// ─── 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 sim = null; +async function bootNgspice() { + if (sim) return sim; + spice('Booting ngspice-WASM...'); + sim = new Simulation(); + await sim.start(); + spice('ngspice ready'); + return sim; +} + +async function solveCircuit(r1, r2, vcc = 3.3) { + const engine = await bootNgspice(); + const netlist = `Voltage divider R1=${r1} R2=${r2} +V1 vcc 0 DC ${vcc} +R1 vcc mid ${r1} +R2 mid 0 ${r2} +.op +.end`; + engine.setNetList(netlist); + const result = await engine.runSim(); + const names = result.variableNames.map(n => n.toLowerCase()); + const idx = names.indexOf('v(mid)'); + if (idx < 0) throw new Error(`v(mid) not found in result: ${names}`); + const voltage = result.data[idx].values[0]; + spice(`Solved: R1=${r1}, R2=${r2}, V(mid) = ${voltage.toFixed(4)}V`); + return voltage; +} + +// ─── Step 1: Compile ────────────────────────────────────────────────────────── +async function compile() { + info('Compiling ESP32 ADC 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) { + const text = await res.text(); + throw new Error(`Compilation HTTP ${res.status}: ${text.slice(0, 500)}`); + } + const body = await res.json(); + if (!body.success) { + throw new Error(`Compilation error:\n${(body.error ?? body.stderr ?? 'unknown').slice(0, 500)}`); + } + const firmware_b64 = body.binary_content ?? body.firmware_b64; + if (!firmware_b64) throw new Error(`No firmware. Keys: ${Object.keys(body)}`); + ok(`Compiled -- ${Math.round(firmware_b64.length * 0.75 / 1024)} KB firmware`); + return firmware_b64; +} + +// ─── Step 2: Run co-simulation ──────────────────────────────────────────────── +function runCoSimulation(firmware_b64) { + return new Promise(async (resolve) => { + // Pre-solve two circuit configurations with ngspice + const v1 = await solveCircuit(10000, 10000, 3.3); // 1.65V + const v2 = await solveCircuit(10000, 30000, 3.3); // 2.475V + + const wsUrl = `${WS_BASE}/api/simulation/ws/${SESSION}`; + info(`Connecting WebSocket -> ${wsUrl}`); + + const ws = new WebSocket(wsUrl); + let serialLines = []; + let lineBuf = ''; + let readyReceived = false; + let firstInjected = false; + let secondInjected = false; + let firstReadings = []; + let secondReadings = []; + let phase = 0; // 0=boot, 1=injected v1, 2=injected v2 + + const timer = setTimeout(() => { + info(`Timeout (${TIMEOUT_S}s)`); + ws.close(); + resolve({ + timedOut: true, firstReadings, secondReadings, + v1, v2, serialLines, + }); + }, TIMEOUT_S * 1000); + + ws.addEventListener('open', () => { + ok('WebSocket connected'); + ws.send(JSON.stringify({ + type: 'start_esp32', + data: { + board: 'esp32', + firmware_b64, + wifi_enabled: false, + }, + })); + info('Sent start_esp32'); + }); + + ws.addEventListener('message', ev => { + let msg; + try { msg = JSON.parse(ev.data); } catch { return; } + const { type, data } = msg; + + if (type === 'serial_output') { + lineBuf += 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}`); + + // Detect ready signal + if (line.includes('ESP32_ADC_READY') && !readyReceived) { + readyReceived = true; + ok('ESP32 ADC ready -- injecting SPICE voltage #1'); + // Inject v1 into ADC channel 6 (GPIO34) + const mv = Math.round(v1 * 1000); + ws.send(JSON.stringify({ + type: 'esp32_adc_set', + data: { channel: 6, millivolts: mv }, + })); + spice(`Injected V(mid) = ${v1.toFixed(3)}V (${mv} mV) into ADC CH6`); + phase = 1; + firstInjected = true; + } + + // Parse ADC readings + const adcMatch = line.match(/ADC34:\s*raw=(\d+)\s+voltage=([\d.]+)V/); + if (adcMatch) { + const raw = parseInt(adcMatch[1]); + const vRead = parseFloat(adcMatch[2]); + + if (phase === 1) { + firstReadings.push({ raw, voltage: vRead }); + // After 3 readings at v1, switch to v2 + if (firstReadings.length >= 3 && !secondInjected) { + info('3 readings at v1 collected -- injecting SPICE voltage #2'); + const mv2 = Math.round(v2 * 1000); + ws.send(JSON.stringify({ + type: 'esp32_adc_set', + data: { channel: 6, millivolts: mv2 }, + })); + spice(`Injected V(mid) = ${v2.toFixed(3)}V (${mv2} mV) into ADC CH6`); + phase = 2; + secondInjected = true; + } + } else if (phase === 2) { + secondReadings.push({ raw, voltage: vRead }); + if (secondReadings.length >= 3) { + clearTimeout(timer); + ws.close(); + resolve({ + timedOut: false, firstReadings, secondReadings, + v1, v2, serialLines, + }); + } + } + } + } + return; + } + + if (type === 'system') info(`system: ${JSON.stringify(data)}`); + if (type === 'error') err(`error: ${JSON.stringify(data)}`); + }); + + ws.addEventListener('close', () => { + clearTimeout(timer); + if (phase < 2) { + resolve({ + timedOut: true, firstReadings, secondReadings, + v1, v2, serialLines, + }); + } + }); + + ws.addEventListener('error', e => { + err(`WebSocket error: ${e.message ?? e}`); + }); + }); +} + +// ─── Step 3: Validate results ──────────────────────────────────────────────── +function validate(result) { + const { timedOut, firstReadings, secondReadings, v1, v2 } = result; + + info(''); + info('═══════════════════════════════════════════════════'); + info(' Co-Simulation Results: ESP32 + ngspice'); + info('═══════════════════════════════════════════════════'); + + // Expected ADC raw values (12-bit, 3.3V reference) + const expected1 = Math.round(v1 / 3.3 * 4095); + const expected2 = Math.round(v2 / 3.3 * 4095); + + info(`Circuit 1: R1=10k, R2=10k -> V(mid)=${v1.toFixed(4)}V -> expected ADC=${expected1}`); + info(`Circuit 2: R1=10k, R2=30k -> V(mid)=${v2.toFixed(4)}V -> expected ADC=${expected2}`); + info(''); + + let pass = true; + + if (timedOut) { + err('Test timed out before collecting enough readings'); + pass = false; + } + + // Check first batch + if (firstReadings.length < 1) { + err('No ADC readings received after first injection'); + pass = false; + } else { + const avg1 = firstReadings.reduce((s, r) => s + r.raw, 0) / firstReadings.length; + info(`First batch: ${firstReadings.length} readings, avg raw=${avg1.toFixed(0)} (expected ${expected1})`); + // Tolerance: ±50 counts (generous for QEMU ADC emulation + timing) + if (Math.abs(avg1 - expected1) > 50) { + err(`First batch off by ${Math.abs(avg1 - expected1).toFixed(0)} counts (tolerance: 50)`); + pass = false; + } else { + ok(`First batch within tolerance`); + } + } + + // Check second batch + if (secondReadings.length < 1) { + err('No ADC readings received after second injection'); + pass = false; + } else { + const avg2 = secondReadings.reduce((s, r) => s + r.raw, 0) / secondReadings.length; + info(`Second batch: ${secondReadings.length} readings, avg raw=${avg2.toFixed(0)} (expected ${expected2})`); + if (Math.abs(avg2 - expected2) > 50) { + err(`Second batch off by ${Math.abs(avg2 - expected2).toFixed(0)} counts (tolerance: 50)`); + pass = false; + } else { + ok(`Second batch within tolerance`); + } + } + + // Check that the two batches are DIFFERENT (proving the circuit change was detected) + if (firstReadings.length > 0 && secondReadings.length > 0) { + const avg1 = firstReadings.reduce((s, r) => s + r.raw, 0) / firstReadings.length; + const avg2 = secondReadings.reduce((s, r) => s + r.raw, 0) / secondReadings.length; + if (Math.abs(avg2 - avg1) < 100) { + err(`First and second batches too similar (delta=${Math.abs(avg2 - avg1).toFixed(0)}). Circuit change not detected.`); + pass = false; + } else { + ok(`Circuit change detected: delta=${Math.abs(avg2 - avg1).toFixed(0)} counts`); + } + } + + info(''); + if (pass) { + ok('ALL CHECKS PASSED -- ESP32 + ngspice co-simulation works!'); + process.exit(0); + } else { + err('SOME CHECKS FAILED'); + process.exit(1); + } +} + +// ─── Main ───────────────────────────────────────────────────────────────────── +async function main() { + info('ESP32 + ngspice analog co-simulation E2E test'); + info(`Backend: ${BACKEND}`); + info(`Timeout: ${TIMEOUT_S}s`); + info(''); + + try { + // Boot ngspice engine (async, ~400ms) + await bootNgspice(); + + // Compile sketch + const firmware = await compile(); + + // Run the co-simulation + const result = await runCoSimulation(firmware); + + // Validate + 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(); diff --git a/test/backend/e2e/test_esp32_spice_ntc_bridge.mjs b/test/backend/e2e/test_esp32_spice_ntc_bridge.mjs new file mode 100644 index 00000000..9e4d3960 --- /dev/null +++ b/test/backend/e2e/test_esp32_spice_ntc_bridge.mjs @@ -0,0 +1,329 @@ +/** + * test_esp32_spice_ntc_bridge.mjs + * + * Advanced co-simulation: ESP32 reads a Wheatstone bridge with an NTC + * thermistor through its ADC, and the bridge voltages are computed by + * ngspice-WASM. The test sweeps temperature from 0C to 50C and verifies + * the ESP32's calculated temperature matches within tolerance. + * + * Circuit (ngspice): + * Vcc=3.3V + * | + * R1=10k R3=10k + * | | + * VA (ADC34) VB (ADC35) + * | | + * NTC(T) R4=10k (fixed reference) + * | | + * GND GND + * + * V_diff = VA - VB (proportional to NTC deviation from 10k) + * + * Sketch: reads ADC34 and ADC35, computes V_diff, estimates temperature + * from the NTC beta-model, and prints via Serial. + * + * Run: + * cd test/backend/e2e && npm install && node test_esp32_spice_ntc_bridge.mjs + * + * Prerequisites: Backend on http://localhost:8001, esp32 core installed. + */ + +import { Simulation } from 'eecircuit-engine'; + +const BACKEND = process.env.BACKEND_URL ?? 'http://localhost:8001'; +const WS_BASE = BACKEND.replace(/^https?:/, m => m === 'https:' ? 'wss:' : 'ws:'); +const SESSION = `test-esp32-ntc-${Date.now()}`; +const TIMEOUT_S = parseInt(process.argv.find(a => a.startsWith('--timeout='))?.slice(10) ?? '120'); + +// NTC beta model (matches the sketch) +const NTC_R0 = 10000; // 10k at 25C +const NTC_T0 = 298.15; // 25C in Kelvin +const NTC_BETA = 3950; + +function ntcResistance(Tc) { + const T = Tc + 273.15; + return NTC_R0 * Math.exp(NTC_BETA * (1 / T - 1 / NTC_T0)); +} + +// ─── ESP32 Sketch ──────────────────────────────────────────────────────────── +const SKETCH = `// ESP32 Wheatstone bridge + NTC temperature reader +// ADC34 = bridge leg A (NTC side) +// ADC35 = bridge leg B (reference side) + +#define NTC_R0 10000.0 +#define NTC_T0 298.15 +#define NTC_BETA 3950.0 +#define R_PULL 10000.0 +#define VCC 3.3 + +void setup() { + Serial.begin(115200); + analogReadResolution(12); + delay(500); + Serial.println("ESP32_BRIDGE_READY"); +} + +void loop() { + int rawA = analogRead(34); + int rawB = analogRead(35); + float vA = rawA * VCC / 4095.0; + float vB = rawB * VCC / 4095.0; + + // Estimate NTC resistance from VA (half-bridge: Vcc -> R_pull -> VA -> NTC -> GND) + // VA = VCC * R_ntc / (R_pull + R_ntc) => R_ntc = R_pull * VA / (VCC - VA) + float rNtc = R_PULL * vA / (VCC - vA + 0.001); + + // Beta model: T = 1 / (1/T0 + ln(R/R0)/beta) + float tK = 1.0 / (1.0 / NTC_T0 + log(rNtc / NTC_R0) / NTC_BETA); + float tC = tK - 273.15; + + Serial.printf("BRIDGE: rawA=%d rawB=%d vA=%.3f vB=%.3f R_ntc=%.0f T=%.1fC\\n", + rawA, rawB, vA, vB, rNtc, tC); + delay(500); +}`; + +// ─── 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 ────────────────────────────────────────────────────────────────── +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; +} + +async function solveBridge(tempC) { + const rNtc = ntcResistance(tempC); + const e = await bootNgspice(); + const netlist = `Wheatstone bridge T=${tempC}C +V1 vcc 0 DC 3.3 +R1 vcc va 10k +Rntc va 0 ${rNtc} +R3 vcc vb 10k +R4 vb 0 10k +.op +.end`; + e.setNetList(netlist); + const result = await e.runSim(); + const names = result.variableNames.map(n => n.toLowerCase()); + const iA = names.indexOf('v(va)'); + const iB = names.indexOf('v(vb)'); + if (iA < 0 || iB < 0) throw new Error(`Nets not found: ${names}`); + const vA = result.data[iA].values[0]; + const vB = result.data[iB].values[0]; + spice(`T=${tempC}C: R_ntc=${rNtc.toFixed(0)} VA=${vA.toFixed(4)} VB=${vB.toFixed(4)}`); + return { vA, vB, rNtc }; +} + +// ─── Compile ────────────────────────────────────────────────────────────────── +async function compile() { + info('Compiling ESP32 bridge 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. Keys: ${Object.keys(body)}`); + ok(`Compiled -- ${Math.round(fw.length * 0.75 / 1024)} KB`); + return fw; +} + +// ─── Co-simulation ──────────────────────────────────────────────────────────── +function runCoSim(firmware_b64) { + // Temperature sweep: 0C, 25C, 50C + const temps = [0, 25, 50]; + + return new Promise(async (resolve) => { + // Pre-solve all circuits + const circuits = {}; + for (const t of temps) { + circuits[t] = await solveBridge(t); + } + + const wsUrl = `${WS_BASE}/api/simulation/ws/${SESSION}`; + info(`Connecting WebSocket -> ${wsUrl}`); + const ws = new WebSocket(wsUrl); + + let lineBuf = ''; + let serialLines = []; + let ready = false; + let tempIdx = 0; + let results = {}; // temp -> [{rawA, rawB, vA, vB, rNtc, tC}] + let currentTemp = temps[0]; + + const timer = setTimeout(() => { + ws.close(); + resolve({ timedOut: true, results, serialLines, circuits }); + }, TIMEOUT_S * 1000); + + function injectVoltage(tempC) { + const c = circuits[tempC]; + const mvA = Math.round(c.vA * 1000); + const mvB = Math.round(c.vB * 1000); + ws.send(JSON.stringify({ type: 'esp32_adc_set', data: { channel: 6, millivolts: mvA } })); + ws.send(JSON.stringify({ type: 'esp32_adc_set', data: { channel: 7, millivolts: mvB } })); + spice(`Injected T=${tempC}C: CH6=${mvA}mV CH7=${mvB}mV`); + } + + 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') { + 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_BRIDGE_READY') && !ready) { + ready = true; + ok('ESP32 bridge ready -- injecting first temperature'); + currentTemp = temps[0]; + injectVoltage(currentTemp); + } + + const m = line.match(/BRIDGE:\s*rawA=(\d+)\s+rawB=(\d+)\s+vA=([\d.]+)\s+vB=([\d.]+)\s+R_ntc=([\d.]+)\s+T=([-\d.]+)C/); + if (m) { + const reading = { + rawA: parseInt(m[1]), rawB: parseInt(m[2]), + vA: parseFloat(m[3]), vB: parseFloat(m[4]), + rNtc: parseFloat(m[5]), tC: parseFloat(m[6]), + }; + if (!results[currentTemp]) results[currentTemp] = []; + results[currentTemp].push(reading); + + // After 2 readings at this temp, move to next + if (results[currentTemp].length >= 2) { + tempIdx++; + if (tempIdx < temps.length) { + currentTemp = temps[tempIdx]; + info(`Switching to T=${currentTemp}C`); + injectVoltage(currentTemp); + } else { + clearTimeout(timer); + ws.close(); + resolve({ timedOut: false, results, serialLines, circuits }); + } + } + } + } + } + + if (msg.type === 'system') info(`system: ${JSON.stringify(msg.data)}`); + if (msg.type === 'error') err(`error: ${JSON.stringify(msg.data)}`); + }); + + ws.addEventListener('error', e => err(`WS error: ${e.message ?? e}`)); + ws.addEventListener('close', () => { + clearTimeout(timer); + }); + }); +} + +// ─── Validation ─────────────────────────────────────────────────────────────── +function validate(result) { + const { timedOut, results, circuits } = result; + info(''); + info('══════════════════════════════════════════════════════════════'); + info(' Co-Simulation Results: ESP32 + ngspice Wheatstone Bridge'); + info('══════════════════════════════════════════════════════════════'); + + let pass = true; + if (timedOut) { err('Timed out'); pass = false; } + + for (const [tempStr, readings] of Object.entries(results)) { + const temp = parseInt(tempStr); + if (readings.length === 0) { err(`No readings for T=${temp}C`); pass = false; continue; } + + const avgT = readings.reduce((s, r) => s + r.tC, 0) / readings.length; + const c = circuits[temp]; + info(`T=${temp}C: SPICE V(A)=${c.vA.toFixed(3)}V, R_ntc=${c.rNtc.toFixed(0)}ohm`); + info(` ESP32 read: avgT=${avgT.toFixed(1)}C (${readings.length} samples)`); + + // Tolerance: +/- 5C (ADC quantization + beta model rounding) + if (Math.abs(avgT - temp) > 5) { + err(` Temperature off by ${Math.abs(avgT - temp).toFixed(1)}C (tolerance: 5C)`); + pass = false; + } else { + ok(` Within tolerance`); + } + } + + // Check that different temperatures produce different readings + const temps = Object.keys(results).map(Number).sort((a, b) => a - b); + if (temps.length >= 2) { + const first = results[temps[0]]; + const last = results[temps[temps.length - 1]]; + if (first?.length > 0 && last?.length > 0) { + const delta = Math.abs(first[0].rawA - last[0].rawA); + if (delta < 50) { + err(`ADC readings too similar across temperatures (delta=${delta})`); + pass = false; + } else { + ok(`Temperature sweep produces distinct ADC readings (delta=${delta})`); + } + } + } + + info(''); + if (pass) { + ok('ALL CHECKS PASSED -- ESP32 Wheatstone bridge + ngspice co-simulation works!'); + process.exit(0); + } else { + err('SOME CHECKS FAILED'); + process.exit(1); + } +} + +// ─── Main ───────────────────────────────────────────────────────────────────── +async function main() { + info('ESP32 + ngspice Wheatstone bridge co-simulation E2E test'); + info(`Backend: ${BACKEND} | 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(); diff --git a/test/backend/e2e/test_esp32_spice_smoke.mjs b/test/backend/e2e/test_esp32_spice_smoke.mjs new file mode 100644 index 00000000..efbaebe9 --- /dev/null +++ b/test/backend/e2e/test_esp32_spice_smoke.mjs @@ -0,0 +1,27 @@ +/** + * Smoke test: verify that eecircuit-engine (ngspice-WASM) runs standalone in + * the E2E test environment. Does NOT require the backend. + * + * Run: node test/backend/e2e/test_esp32_spice_smoke.mjs + */ +import { Simulation } from 'eecircuit-engine'; + +const sim = new Simulation(); +await sim.start(); + +sim.setNetList(`Smoke test +V1 vcc 0 DC 3.3 +R1 vcc mid 10k +R2 mid 0 10k +.op +.end`); + +const result = await sim.runSim(); +const names = result.variableNames.map(n => n.toLowerCase()); +const idx = names.indexOf('v(mid)'); +const v = result.data[idx].values[0]; + +console.log(`v(mid) = ${v.toFixed(4)} V (expected 1.6500)`); +const ok = Math.abs(v - 1.65) < 0.01; +console.log(ok ? 'PASS' : 'FAIL'); +process.exit(ok ? 0 : 1);