velxio/test/backend/e2e/test_esp32_spice_ntc_bridge...

330 lines
12 KiB
JavaScript

/**
* 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();