2026-04-21 09:56:42 +07:00
/ * *
* 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' ;
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' ;
2026-04-21 09:56:42 +07:00
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 : [
2026-04-22 02:45:45 +07:00
{
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
} ,
2026-04-21 09:56:42 +07:00
] ,
} ;
}
// 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 ] ;
2026-04-22 02:45:45 +07:00
let lo = 0 ,
hi = last ;
2026-04-21 09:56:42 +07:00
while ( lo + 1 < hi ) {
const mid = ( lo + hi ) >> 1 ;
2026-04-22 02:45:45 +07:00
if ( ts [ mid ] <= t ) lo = mid ;
else hi = mid ;
2026-04-21 09:56:42 +07:00
}
2026-04-22 02:45:45 +07:00
const t0 = ts [ lo ] ,
t1 = ts [ hi ] ;
2026-04-21 09:56:42 +07:00
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 ) ;
2026-04-22 02:45:45 +07:00
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 ,
} ) ;
2026-04-21 09:56:42 +07:00
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 [ ] ;
2026-04-22 02:45:45 +07:00
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 ) ) ,
) ;
2026-04-21 09:56:42 +07:00
const peak = Math . max ( . . . wf ) ;
expect ( peak ) . toBeGreaterThan ( 3.0 ) ;
// ── L4 ────────────────────────────────────────────────────────────────
2026-05-16 02:46:34 +07:00
console . log ( '\n=== L4 solveInput ===' ) ;
const result = await solveInput ( input ) ;
2026-04-21 09:56:42 +07:00
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 ────────────────────────────────────────────────────────────────
2026-04-21 20:56:20 +07:00
// `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.
2026-04-21 09:56:42 +07:00
console . log ( '\n=== L5 interpolateAt sanity at 8 phases ===' ) ;
const rtw = result . timeWaveforms ! ;
const rSamples = rtw . nodes . get ( a0Net ) ! ;
2026-04-21 20:56:20 +07:00
const signalFreqHz = 50 ;
const signalPeriodS = 1 / signalFreqHz ;
2026-04-21 09:56:42 +07:00
const phases : Array < { t : number ; v : number } > = [ ] ;
for ( const q of [ 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 ] ) {
2026-04-21 20:56:20 +07:00
const t = ( q / 8 ) * signalPeriodS ;
2026-04-21 09:56:42 +07:00
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 ( ) ) ;
2026-04-22 02:45:45 +07:00
const mockSim = {
getADC : ( ) = > avr . adc ,
getCurrentCycles : ( ) = > avr . cpu . cycles ,
} as unknown as Parameters < typeof setAdcVoltage > [ 0 ] ;
2026-04-21 09:56:42 +07:00
const ok25 = setAdcVoltage ( mockSim , 14 , 2.5 ) ;
2026-04-22 02:45:45 +07:00
console . log (
'setAdcVoltage(mockSim, 14, 2.5) returned' ,
ok25 ,
'channelValues[0]=' ,
avr . adc . channelValues [ 0 ] ,
) ;
2026-04-21 09:56:42 +07:00
expect ( ok25 ) . toBe ( true ) ;
expect ( avr . adc . channelValues [ 0 ] ) . toBeCloseTo ( 2.5 , 3 ) ;
avr . runCycles ( 80 _000 ) ;
const adch25 = avr . reg ( 0x79 ) ;
2026-04-22 02:45:45 +07:00
console . log (
'ADCH after AVR run with 2.5 V on ch0:' ,
adch25 ,
'(expected ~128 for ADLAR left-shift of 512/1024 ≈ 0.5)' ,
) ;
2026-04-21 09:56:42 +07:00
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 ( ) ) ;
2026-04-22 02:45:45 +07:00
const freshMock = {
getADC : ( ) = > freshAvr . adc ,
getCurrentCycles : ( ) = > freshAvr . cpu . cycles ,
} as unknown as Parameters < typeof setAdcVoltage > [ 0 ] ;
2026-04-21 09:56:42 +07:00
const CPU_HZ = 16 _000_000 ;
2026-04-22 02:45:45 +07:00
const STEP_CYCLES = 16 _000 ; // 1 ms of AVR
const STEPS = 200 ; // → 200 ms total
2026-04-21 09:56:42 +07:00
const adcSeries : number [ ] = [ ] ;
const adchSeries : number [ ] = [ ] ;
for ( let i = 0 ; i < STEPS ; i ++ ) {
const simT = freshAvr . cpu . cycles / CPU_HZ ;
2026-04-21 20:56:20 +07:00
const t = simT % signalPeriodS ;
2026-04-21 09:56:42 +07:00
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 } ` ) ;
2026-04-22 02:45:45 +07:00
console . log (
'first 30 ADC voltages:' ,
adcSeries . slice ( 0 , 30 ) . map ( ( v ) = > v . toFixed ( 2 ) ) ,
) ;
2026-04-21 09:56:42 +07:00
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 ) ;
} ) ;
2026-04-21 20:56:20 +07:00
// ── 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.
2026-04-21 09:56:42 +07:00
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.