test(e2e): add ESP32 + SPICE photodiode co-simulation test
Follows the same pattern as test_esp32_spice_analog.mjs and test_esp32_spice_ntc_bridge.mjs: compile an ESP32 sketch on-the-fly via /api/compile/, boot QEMU through the backend WebSocket, and sweep lux levels while verifying analogRead() returns what ngspice solved. The netlist uses the exact photodiode cards emitted by frontend/.../componentToSpice.ts (D_<id> + I_<id>_ph + DPHOTO model), so any drift in the frontend SPICE mapper surfaces here. Validated against the live Docker container — lux=0/1000/2500 produce raw=4095/2854/992, matching the expected 4095/2854/993 within ±1 LSB and monotonically decreasing with brightness as expected. The package-lock.json churn is a pre-existing drift: eecircuit-engine was in package.json but missing from the lock — npm install re-added it. Companion workflow change (registering the test in backend-e2e-tests.yml) lives in a separate commit that requires a PAT with workflow scope to push.
This commit is contained in:
parent
4d17dfd832
commit
2bc492c16f
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
Loading…
Reference in New Issue