278 lines
13 KiB
TypeScript
278 lines
13 KiB
TypeScript
/**
|
|
* Reproduce the live-app failure of the "Half-Wave Rectifier" example.
|
|
*
|
|
* This test recreates every layer of Velxio's runtime pipeline so we can
|
|
* pinpoint which step fails when `analogRead(A0)` always returns 0 in the
|
|
* running app:
|
|
*
|
|
* L1. buildInputFromStore — does the adapter pick `.tran`?
|
|
* L2. buildNetlist — does the netlist have SIN + diode?
|
|
* does pinNetMap contain `arduino-uno:A0`?
|
|
* L3. runNetlist (ngspice) — does the solve converge? produce a
|
|
* rectified waveform on the A0 net?
|
|
* L4. CircuitScheduler.solveNow — does the result propagate with
|
|
* `timeWaveforms` populated?
|
|
* L5. interpolation — does interpolateAt(ts, vs, t) return
|
|
* real samples (not zero) at t ∈ [0, T)?
|
|
* L6. setAdcVoltage → AVRADC — does the partUtils helper write into
|
|
* channelValues[0] correctly?
|
|
* L7. full RAF-replay + AVR loop — simulate the production replay loop
|
|
* against a real AVRADC and confirm
|
|
* `analogRead(A0)` reads varying values.
|
|
* L8. wireElectricalSolver() — invoke the real function against the
|
|
* live stores (just like EditorPage
|
|
* mounts it) with the rectifier already
|
|
* in setComponents/setWires.
|
|
*
|
|
* The AVR program (`adcReadProgram`) continuously triggers an ADC conversion
|
|
* and writes ADCH/ADCL into r20/r21. By polling ADCH across simulated time,
|
|
* we can prove whether the rectified waveform is reaching the MCU.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { buildInputFromStore } from '../simulation/spice/storeAdapter';
|
|
import { buildNetlist } from '../simulation/spice/NetlistBuilder';
|
|
import { solveInput } from './helpers/solveInput';
|
|
import { runNetlist } from './helpers/testSolver';
|
|
import { setAdcVoltage } from '../simulation/parts/partUtils';
|
|
import { AVRTestHarness, adcReadProgram } from './helpers/avrTestHarness';
|
|
|
|
// ── Snapshot mirroring examples-circuits.ts:403 ("Half-Wave Rectifier") ──
|
|
// The shape is what loadExample.ts produces via
|
|
// metadataId: comp.type.replace('wokwi-', '')
|
|
function rectifierSnapshot() {
|
|
return {
|
|
components: [
|
|
{
|
|
id: 'sg1',
|
|
metadataId: 'signal-generator',
|
|
properties: { waveform: 'sine', frequency: 50, amplitude: 5, offset: 0 },
|
|
},
|
|
{ id: 'd1', metadataId: 'diode-1n4007', properties: {} },
|
|
{ id: 'rl', metadataId: 'resistor', properties: { value: '1000' } },
|
|
],
|
|
wires: [
|
|
{
|
|
id: 'w1',
|
|
start: { componentId: 'sg1', pinName: 'SIG' },
|
|
end: { componentId: 'd1', pinName: 'A' },
|
|
},
|
|
{
|
|
id: 'w2',
|
|
start: { componentId: 'd1', pinName: 'C' },
|
|
end: { componentId: 'rl', pinName: '1' },
|
|
},
|
|
{
|
|
id: 'w3',
|
|
start: { componentId: 'rl', pinName: '2' },
|
|
end: { componentId: 'arduino-uno', pinName: 'GND' },
|
|
},
|
|
{
|
|
id: 'w4',
|
|
start: { componentId: 'sg1', pinName: 'GND' },
|
|
end: { componentId: 'arduino-uno', pinName: 'GND' },
|
|
},
|
|
{
|
|
id: 'w5',
|
|
start: { componentId: 'd1', pinName: 'C' },
|
|
end: { componentId: 'arduino-uno', pinName: 'A0' },
|
|
},
|
|
],
|
|
boards: [
|
|
{
|
|
id: 'arduino-uno',
|
|
boardKind: 'arduino-uno' as const,
|
|
pinStates: {}, // Arduino is just observing A0 — no driven pins
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// Copy of subscribeToStore.ts `interpolateAt` so the test stays independent.
|
|
function interpolateAt(ts: number[], vs: number[], t: number): number {
|
|
if (t <= ts[0]) return vs[0];
|
|
const last = ts.length - 1;
|
|
if (t >= ts[last]) return vs[last];
|
|
let lo = 0,
|
|
hi = last;
|
|
while (lo + 1 < hi) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (ts[mid] <= t) lo = mid;
|
|
else hi = mid;
|
|
}
|
|
const t0 = ts[lo],
|
|
t1 = ts[hi];
|
|
if (t1 === t0) return vs[lo];
|
|
const a = (t - t0) / (t1 - t0);
|
|
return vs[lo] * (1 - a) + vs[hi] * a;
|
|
}
|
|
|
|
describe('Half-Wave Rectifier — layer-by-layer reproduction', () => {
|
|
it('traces every pipeline layer with logs so we can spot the failure point', async () => {
|
|
// ── L1 ────────────────────────────────────────────────────────────────
|
|
const snap = rectifierSnapshot();
|
|
const input = buildInputFromStore(snap);
|
|
console.log('\n=== L1 buildInputFromStore ===');
|
|
console.log('analysis:', input.analysis);
|
|
console.log(
|
|
'components:',
|
|
input.components.map((c) => ({ id: c.id, meta: c.metadataId })),
|
|
);
|
|
console.log('boards[0]:', {
|
|
id: input.boards[0].id,
|
|
vcc: input.boards[0].vcc,
|
|
pins: input.boards[0].pins,
|
|
gnd: input.boards[0].groundPinNames,
|
|
vccPins: input.boards[0].vccPinNames,
|
|
});
|
|
expect(input.analysis.kind).toBe('tran');
|
|
expect(input.components.some((c) => c.metadataId === 'signal-generator')).toBe(true);
|
|
|
|
// ── L2 ────────────────────────────────────────────────────────────────
|
|
const { netlist, pinNetMap } = buildNetlist(input);
|
|
console.log('\n=== L2 buildNetlist ===');
|
|
console.log('netlist:\n' + netlist);
|
|
console.log('pinNetMap entries:', [...pinNetMap.entries()]);
|
|
const a0Key = 'arduino-uno:A0';
|
|
expect(pinNetMap.has(a0Key)).toBe(true);
|
|
const a0Net = pinNetMap.get(a0Key)!;
|
|
console.log('A0 pin resolves to net:', a0Net);
|
|
expect(netlist).toMatch(/SIN\(/);
|
|
expect(netlist).toMatch(/\.tran\b/);
|
|
|
|
// ── L3 ────────────────────────────────────────────────────────────────
|
|
console.log('\n=== L3 runNetlist (ngspice) ===');
|
|
const cooked = await runNetlist(netlist);
|
|
console.log('variableNames:', cooked.variableNames);
|
|
const times = cooked.vec('time') as number[];
|
|
console.log('time points:', times.length, 'first:', times[0], 'last:', times[times.length - 1]);
|
|
const wfName = `v(${a0Net})`;
|
|
expect(cooked.variableNames.map((n) => n.toLowerCase())).toContain(wfName.toLowerCase());
|
|
const wf = cooked.vec(wfName) as number[];
|
|
console.log(
|
|
`${wfName} samples: peak=${Math.max(...wf).toFixed(3)} V min=${Math.min(...wf).toFixed(3)} V mean=${(wf.reduce((a, b) => a + b, 0) / wf.length).toFixed(3)} V`,
|
|
);
|
|
console.log(
|
|
`${wfName} first 12 samples:`,
|
|
wf.slice(0, 12).map((v) => v.toFixed(3)),
|
|
);
|
|
const peak = Math.max(...wf);
|
|
expect(peak).toBeGreaterThan(3.0);
|
|
|
|
// ── L4 ────────────────────────────────────────────────────────────────
|
|
console.log('\n=== L4 solveInput ===');
|
|
const result = await solveInput(input);
|
|
console.log('analysisMode:', result.analysisMode);
|
|
console.log('converged:', result.converged, 'error:', result.error);
|
|
console.log('nodeVoltage keys:', Object.keys(result.nodeVoltages));
|
|
console.log('pinNetMap keys:', [...result.pinNetMap.keys()]);
|
|
console.log('timeWaveforms present:', !!result.timeWaveforms);
|
|
if (result.timeWaveforms) {
|
|
console.log('timeWaveforms nodes:', [...result.timeWaveforms.nodes.keys()]);
|
|
console.log('timeWaveforms branches:', [...result.timeWaveforms.branches.keys()]);
|
|
}
|
|
expect(result.timeWaveforms).toBeDefined();
|
|
expect(result.timeWaveforms!.nodes.has(a0Net)).toBe(true);
|
|
|
|
// ── L5 ────────────────────────────────────────────────────────────────
|
|
// `rtw.time[last]` is the `.tran` STOP time (~80 ms — four periods of the
|
|
// 50 Hz signal), not the signal period. Sample 8 phases across one real
|
|
// signal period (1/50 Hz = 20 ms); anything else aliases against the sine.
|
|
console.log('\n=== L5 interpolateAt sanity at 8 phases ===');
|
|
const rtw = result.timeWaveforms!;
|
|
const rSamples = rtw.nodes.get(a0Net)!;
|
|
const signalFreqHz = 50;
|
|
const signalPeriodS = 1 / signalFreqHz;
|
|
const phases: Array<{ t: number; v: number }> = [];
|
|
for (const q of [0, 1, 2, 3, 4, 5, 6, 7]) {
|
|
const t = (q / 8) * signalPeriodS;
|
|
const v = interpolateAt(rtw.time, rSamples, t);
|
|
phases.push({ t, v });
|
|
console.log(` t = ${(t * 1000).toFixed(2)} ms → V(A0) = ${v.toFixed(3)} V`);
|
|
}
|
|
const vMax = Math.max(...phases.map((p) => p.v));
|
|
const vMin = Math.min(...phases.map((p) => p.v));
|
|
console.log(`interpolated vMax=${vMax.toFixed(3)} vMin=${vMin.toFixed(3)}`);
|
|
expect(vMax).toBeGreaterThan(1.5);
|
|
|
|
// ── L6 ────────────────────────────────────────────────────────────────
|
|
console.log('\n=== L6 setAdcVoltage → AVRADC ===');
|
|
const avr = new AVRTestHarness();
|
|
avr.loadProgram(adcReadProgram());
|
|
const mockSim = {
|
|
getADC: () => avr.adc,
|
|
getCurrentCycles: () => avr.cpu.cycles,
|
|
} as unknown as Parameters<typeof setAdcVoltage>[0];
|
|
const ok25 = setAdcVoltage(mockSim, 14, 2.5);
|
|
console.log(
|
|
'setAdcVoltage(mockSim, 14, 2.5) returned',
|
|
ok25,
|
|
'channelValues[0]=',
|
|
avr.adc.channelValues[0],
|
|
);
|
|
expect(ok25).toBe(true);
|
|
expect(avr.adc.channelValues[0]).toBeCloseTo(2.5, 3);
|
|
avr.runCycles(80_000);
|
|
const adch25 = avr.reg(0x79);
|
|
console.log(
|
|
'ADCH after AVR run with 2.5 V on ch0:',
|
|
adch25,
|
|
'(expected ~128 for ADLAR left-shift of 512/1024 ≈ 0.5)',
|
|
);
|
|
expect(adch25).toBeGreaterThan(0);
|
|
|
|
// ── L7 ────────────────────────────────────────────────────────────────
|
|
// Full RAF-replay simulation: step AVR through simulated time, replay
|
|
// the rectified waveform into channelValues[0] at each frame. This is
|
|
// the exact loop that runs inside subscribeToStore.ts:adcReplayFrame.
|
|
console.log('\n=== L7 full replay loop over 80 ms of AVR time ===');
|
|
const freshAvr = new AVRTestHarness();
|
|
freshAvr.loadProgram(adcReadProgram());
|
|
const freshMock = {
|
|
getADC: () => freshAvr.adc,
|
|
getCurrentCycles: () => freshAvr.cpu.cycles,
|
|
} as unknown as Parameters<typeof setAdcVoltage>[0];
|
|
const CPU_HZ = 16_000_000;
|
|
const STEP_CYCLES = 16_000; // 1 ms of AVR
|
|
const STEPS = 200; // → 200 ms total
|
|
const adcSeries: number[] = [];
|
|
const adchSeries: number[] = [];
|
|
for (let i = 0; i < STEPS; i++) {
|
|
const simT = freshAvr.cpu.cycles / CPU_HZ;
|
|
const t = simT % signalPeriodS;
|
|
const v = interpolateAt(rtw.time, rSamples, t);
|
|
setAdcVoltage(freshMock, 14, Math.max(0, Math.min(5, v)));
|
|
freshAvr.runCycles(STEP_CYCLES);
|
|
adcSeries.push(freshAvr.adc.channelValues[0]);
|
|
adchSeries.push(freshAvr.reg(0x79));
|
|
}
|
|
const hi = adcSeries.filter((v) => v > 1.5).length;
|
|
const lo = adcSeries.filter((v) => v < 0.2).length;
|
|
console.log(`channelValues[0] over ${STEPS} ms: highs(>1.5V)=${hi}, lows(<0.2V)=${lo}`);
|
|
console.log(
|
|
'first 30 ADC voltages:',
|
|
adcSeries.slice(0, 30).map((v) => v.toFixed(2)),
|
|
);
|
|
console.log('first 30 ADCH reads:', adchSeries.slice(0, 30));
|
|
const maxAdch = Math.max(...adchSeries);
|
|
console.log('max ADCH seen by AVR:', maxAdch);
|
|
expect(hi).toBeGreaterThanOrEqual(20);
|
|
expect(lo).toBeGreaterThanOrEqual(20);
|
|
expect(maxAdch).toBeGreaterThan(100);
|
|
}, 60_000);
|
|
});
|
|
|
|
// ── L8 extracted to `spice-rectifier-live-bootstrap.test.ts` ─────────────
|
|
// The live-bootstrap block ran against the real singleton ngspice-WASM
|
|
// engine. When L1/L3 solved first in the same process, realloc exploded
|
|
// with "Not enough memory or heap corruption" and the electrical store
|
|
// fell back to `op`. Moving the block into its own file gives Vitest
|
|
// worker isolation — and a pristine WASM instance — to the test.
|
|
|
|
// ── L9 deleted in Phase 1c step C ────────────────────────────────────────
|
|
// The pre-existing flaky "wireElectricalSolver queues NO RAF" block was
|
|
// removed when ADC injection moved into `connectAnalogInputsToMcu.ts`. It
|
|
// asserted implementation details (RAF replay path was gone) instead of
|
|
// real behaviour. End-to-end ADC bridge coverage lives in
|
|
// circuit-simulation-service.test.ts and the BJT-switch integration test,
|
|
// both of which go through real SPICE solve → useElectricalStore → bridge.
|