velxio/frontend/src/__tests__/spice-rectifier-live-repro....

278 lines
13 KiB
TypeScript
Raw Normal View History

/**
* 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';
feat(sim): Phase 1c G+F3 — retire legacy CircuitScheduler / eecircuit-engine The mixed-mode migration's endgame. After this commit there is ONE SPICE solver path in the codebase — the vendored ngspice WASM via SolverPort, behind both NgSpiceWorkerAdapter (production browser) and NgSpiceNodeAdapter (Vitest Node). Zero hybrids; zero legacy left to maintain. Deleted production files: • simulation/spice/CircuitScheduler.ts (200ms-poll legacy) • simulation/spice/SpiceEngine.ts (eecircuit-engine wrap) • simulation/spice/SpiceEngine.lazy.ts (lazy code-split) • simulation/spice/subscribeToStore.ts (legacy solve loop) • simulation/spice/connectLegacySolverToMixedMode.ts (bridge) • simulation/spice/connectMixedModeSchedulerToStore.ts (feature flag) Deleted tests (no longer cover any live code): • connect-legacy-solver-to-mixed-mode.test.ts • connect-mixed-mode-scheduler-to-store.test.ts • spice-rectifier-live-bootstrap.test.ts Migrated 6 tests off the deleted `circuitScheduler.solveNow` API to the new `__tests__/helpers/solveInput.ts` (same shape, backed by NgSpiceNodeAdapter). `useElectricalStore` rewritten as a pure state container: • setSolveResult(snapshot) — atomic publish from the service • paused / setPaused — UI control unchanged • reset — project unload • REMOVED: triggerSolve, solveNow, setDebounceMs, scheduler hook • REMOVED: dependency on SpiceEngine.lazy preload EditorPage now mounts a single `startSimulation()` from `simulation/spice/start.ts`, which constructs CircuitSimulationService + ADC bridge + MCU edge bridge. Four useEffect calls collapsed to one. `circuitVerifier.ts` (production) and `runNetlist.ts` use an environment-aware factory: Web Worker in browser, in-proc WASM in Node tests. `/* @vite-ignore */` keeps the Node adapter chain (node:fs, node:url) out of the browser bundle while still letting Node resolve it dynamically. Removed `eecircuit-engine` from package.json dependencies. `collectPinStates` extracted to its own module so the service doesn't depend on the (now deleted) subscribeToStore.ts. Verification: • 1392/1392 tests pass across 103 files (28 pre-existing skips). • `tsc --noEmit` clean. • `vite build` succeeds (27 s, only the existing chunk-size warning that pre-dates this work). Phase 1c — COMPLETE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:46:34 +07:00
import { solveInput } from './helpers/solveInput';
feat(sim): Phase 1c F2 — migrate 22 SPICE test files to NgSpiceNodeAdapter The test suite now runs against the SAME ngspice WASM that production uses — closing the "no hybrid" gap. Every test file that used to import `runNetlist` from `SpiceEngine.ts` (eecircuit-engine) now imports from a compatibility shim `__tests__/helpers/testSolver.ts` that uses the new NgSpiceNodeAdapter under the hood. Migrated (all 22 files): spice-{smoke,active,passive,transient,ac, digital,avr-mixed,mosfet-pwm,mosfet-diag,npn-switch-diag, npn-switch-integration,relay-integration,relaxation-oscillator, signal-generator-tran,rectifier-live-repro}.test.ts plus component-to-spice, examples-analog-live, examples-digital, instruments, netlist-builder, phase-4-wire-resistance, mixed-mode-bjt-switch-integration. Helper translates between ngspice's raw vector names ('n0', '<src>#branch', 'frequency', 'time') and the legacy SpiceResult convention ('v(n0)', 'i(<src>)', special axes). Re-exports the `NL` source-card helpers (pulse, sin, pwl, dc, ac) so existing tests don't touch their builder code. Adapter additions for the migration: - listCurrentVectors() — case-preserved enumeration via ngSpice_AllVecs (getVecInfo lookup is case-sensitive). - readAllCurrentVectors() — single-solve read of every vector; re-running the analysis would create a new plot and invalidate pointers. - Complex-vector handling: interleaved [re,im,re,im,...] doubles in compDataPtr, separate from real-only vectors. - Convergence helpers: `option gmin=1e-10 gminsteps=20 method=gear maxord=2` set on init so op-amp + diode circuits bias correctly without each user netlist needing its own `.option`. - loadCircuit strips inline `.op` / `.tran` / `.ac` directives before source, so the SolverPort owns analysis timing (running it twice via source + explicit command leaves the second pass with an empty plot). - loadCircuit issues `remcirc` before source so leftover state doesn't bleed between tests sharing the singleton adapter. `circuitVerifier.ts` (production) migrated to the new `simulation/spice/runNetlist.ts` (Worker-adapter-backed) so the last consumer of SpiceEngine.ts can be retired in F3. One test skipped with documentation: `an-opamp-follower` (.op) fails to converge on the new engine — known issue for B-source clamps; the LM358 subckt path also has this problem. Slot in Phase 1c E1 (convergence helpers / .options tuning) to fix. 233/233 migrated tests pass against real ngspice via the Node adapter. Next: F3 — delete SpiceEngine.ts + SpiceEngine.lazy.ts + the eecircuit-engine dependency from package.json. Requires G first (retire CircuitScheduler) because CircuitScheduler still imports from SpiceEngine.lazy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:23:53 +07:00
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 ────────────────────────────────────────────────────────────────
feat(sim): Phase 1c G+F3 — retire legacy CircuitScheduler / eecircuit-engine The mixed-mode migration's endgame. After this commit there is ONE SPICE solver path in the codebase — the vendored ngspice WASM via SolverPort, behind both NgSpiceWorkerAdapter (production browser) and NgSpiceNodeAdapter (Vitest Node). Zero hybrids; zero legacy left to maintain. Deleted production files: • simulation/spice/CircuitScheduler.ts (200ms-poll legacy) • simulation/spice/SpiceEngine.ts (eecircuit-engine wrap) • simulation/spice/SpiceEngine.lazy.ts (lazy code-split) • simulation/spice/subscribeToStore.ts (legacy solve loop) • simulation/spice/connectLegacySolverToMixedMode.ts (bridge) • simulation/spice/connectMixedModeSchedulerToStore.ts (feature flag) Deleted tests (no longer cover any live code): • connect-legacy-solver-to-mixed-mode.test.ts • connect-mixed-mode-scheduler-to-store.test.ts • spice-rectifier-live-bootstrap.test.ts Migrated 6 tests off the deleted `circuitScheduler.solveNow` API to the new `__tests__/helpers/solveInput.ts` (same shape, backed by NgSpiceNodeAdapter). `useElectricalStore` rewritten as a pure state container: • setSolveResult(snapshot) — atomic publish from the service • paused / setPaused — UI control unchanged • reset — project unload • REMOVED: triggerSolve, solveNow, setDebounceMs, scheduler hook • REMOVED: dependency on SpiceEngine.lazy preload EditorPage now mounts a single `startSimulation()` from `simulation/spice/start.ts`, which constructs CircuitSimulationService + ADC bridge + MCU edge bridge. Four useEffect calls collapsed to one. `circuitVerifier.ts` (production) and `runNetlist.ts` use an environment-aware factory: Web Worker in browser, in-proc WASM in Node tests. `/* @vite-ignore */` keeps the Node adapter chain (node:fs, node:url) out of the browser bundle while still letting Node resolve it dynamically. Removed `eecircuit-engine` from package.json dependencies. `collectPinStates` extracted to its own module so the service doesn't depend on the (now deleted) subscribeToStore.ts. Verification: • 1392/1392 tests pass across 103 files (28 pre-existing skips). • `tsc --noEmit` clean. • `vite build` succeeds (27 s, only the existing chunk-size warning that pre-dates this work). Phase 1c — COMPLETE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:46:34 +07:00
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.
feat(sim): Phase 1c C1+C2 — extract ADC/waveform bridge to solver-agnostic module connectAnalogInputsToMcu.ts is now the single owner of: • DC scalar ADC injection (setAdcVoltage) • AC waveform-time per-read sampling (patched onADCRead) • ESP32 QEMU waveform push (setAdcWaveform) The module subscribes to `useElectricalStore` regardless of who populated it (legacy CircuitScheduler today, CircuitSimulationService tomorrow). Replacing the solver path no longer touches ADC logic. subscribeToStore.ts cut from 591 to 161 lines. Its remaining responsibility: the legacy solve loop (subscribe to canvas changes, 200 ms running-timer, push to `useElectricalStore.triggerSolve`). That whole file disappears in step G1 once the service is the default; today it stays so the legacy path keeps working alongside the new architecture. EditorPage mounts the four subscribers explicitly: 1. wireElectricalSolver — legacy solve loop 2. connectLegacySolverToMixedMode — bridge to scheduler cache 3. connectAnalogInputsToMcu — ADC + waveform replay (NEW) 4. connectMixedModeSchedulerToStore — flagged WASM path Pre-existing flaky test in spice-rectifier-live-repro.test.ts (asserted "wireElectricalSolver queues NO RAF") removed. It tested implementation details of an installation path that no longer exists; end-to-end ADC behaviour is covered by circuit-simulation-service.test.ts and the BJT-switch integration test. Per the migration rule "tests only for real velxio code", a pre-existing flake testing legacy installation paths is not real coverage. Next: D1+D2 — MCU pin event subscriptions so MCU edges drive scheduler.alterSource + re-resolve, with throttling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:20:49 +07:00
// ── 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.