feat: add ESP32 + ngspice co-simulation E2E tests
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) <noreply@anthropic.com>
This commit is contained in:
parent
04d14a74b2
commit
3197935461
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue