2026-05-15 22:22:36 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Phase 1b end-to-end integration — real ngspice driving real
|
|
|
|
|
|
* SpiceResolvedPinResolver subscribers.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Scenario: Arduino pin 9 → 1k → BJT base; collector via 220Ω to 5V;
|
|
|
|
|
|
* emitter to GND. When pin 9 is HIGH (5V), the BJT saturates and the
|
|
|
|
|
|
* collector pulls LOW; when pin 9 is LOW, the collector floats HIGH.
|
|
|
|
|
|
* A SpiceResolvedPinResolver subscribed to "led:A" (whose net equals
|
|
|
|
|
|
* the BJT collector net) must emit HIGH / LOW transitions tracking the
|
|
|
|
|
|
* real SPICE solve.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Coverage:
|
|
|
|
|
|
* - NetlistBuilder produces a usable netlist + pinNetMap
|
|
|
|
|
|
* - runNetlist (eecircuit-engine, real ngspice) solves it
|
|
|
|
|
|
* - connectLegacySolverToMixedModeFor publishes the voltages
|
|
|
|
|
|
* - SpiceResolvedPinResolver threshold-converts via AVR_HC family
|
|
|
|
|
|
*
|
|
|
|
|
|
* Skips the mock — this is the highest-confidence proof we can run in
|
|
|
|
|
|
* a node test that the pipe doesn't have hidden conversion bugs.
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
|
|
|
|
import { buildNetlist } from '../simulation/spice/NetlistBuilder';
|
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-05-15 22:22:36 +07:00
|
|
|
|
import {
|
|
|
|
|
|
getMixedModeScheduler,
|
|
|
|
|
|
__resetMixedModeScheduler,
|
|
|
|
|
|
} from '../simulation/spice/MixedModeScheduler';
|
|
|
|
|
|
import { connectLegacySolverToMixedModeFor } from '../simulation/spice/connectLegacySolverToMixedMode';
|
|
|
|
|
|
import {
|
|
|
|
|
|
createSpiceResolvedPinResolver,
|
|
|
|
|
|
configFromLogicFamily,
|
|
|
|
|
|
} from '../simulation/PinResolver';
|
|
|
|
|
|
import { FAMILIES } from '../simulation/LogicFamilies';
|
|
|
|
|
|
import type { BuildNetlistInput } from '../simulation/spice/types';
|
|
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
|
__resetMixedModeScheduler();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function bjtSwitchNetlist(pin9V: number): BuildNetlistInput {
|
|
|
|
|
|
return {
|
|
|
|
|
|
components: [
|
|
|
|
|
|
{ id: 'rb', metadataId: 'resistor', properties: { value: '1k' } },
|
|
|
|
|
|
{ id: 'rc', metadataId: 'resistor', properties: { value: '220' } },
|
|
|
|
|
|
{ id: 'q1', metadataId: 'bjt-2n2222', properties: {} },
|
|
|
|
|
|
// 'led' isn't here — we model just the BJT and read its collector net.
|
|
|
|
|
|
// Adding a real LED + V-sense would prove the same idea but makes the
|
|
|
|
|
|
// assertion noisier.
|
|
|
|
|
|
],
|
|
|
|
|
|
wires: [
|
|
|
|
|
|
// Arduino 9 → Rb → BJT base
|
|
|
|
|
|
{ id: 'w1', start: { componentId: 'uno', pinName: '9' }, end: { componentId: 'rb', pinName: '1' } },
|
|
|
|
|
|
{ id: 'w2', start: { componentId: 'rb', pinName: '2' }, end: { componentId: 'q1', pinName: 'B' } },
|
|
|
|
|
|
// 5V → Rc → BJT collector
|
|
|
|
|
|
{ id: 'w3', start: { componentId: 'uno', pinName: '5V' }, end: { componentId: 'rc', pinName: '1' } },
|
|
|
|
|
|
{ id: 'w4', start: { componentId: 'rc', pinName: '2' }, end: { componentId: 'q1', pinName: 'C' } },
|
|
|
|
|
|
// BJT emitter → GND
|
|
|
|
|
|
{ id: 'w5', start: { componentId: 'q1', pinName: 'E' }, end: { componentId: 'uno', pinName: 'GND' } },
|
|
|
|
|
|
],
|
|
|
|
|
|
boards: [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'uno',
|
|
|
|
|
|
vcc: 5,
|
|
|
|
|
|
pins: {
|
|
|
|
|
|
'5V': { type: 'digital', v: 5 },
|
|
|
|
|
|
GND: { type: 'digital', v: 0 },
|
|
|
|
|
|
'9': { type: 'digital', v: pin9V },
|
|
|
|
|
|
},
|
|
|
|
|
|
groundPinNames: ['GND'],
|
|
|
|
|
|
vccPinNames: ['5V'],
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
analysis: { kind: 'op' },
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Solve, then return the (nodeVoltages, pinNetMap) snapshot in the shape
|
|
|
|
|
|
* connectLegacySolverToMixedModeFor expects. */
|
|
|
|
|
|
async function solveAndSnapshot(input: BuildNetlistInput): Promise<{
|
|
|
|
|
|
nodeVoltages: Record<string, number>;
|
|
|
|
|
|
pinNetMap: Map<string, string>;
|
|
|
|
|
|
}> {
|
|
|
|
|
|
const { netlist, pinNetMap } = buildNetlist(input);
|
|
|
|
|
|
const result = await runNetlist(netlist);
|
|
|
|
|
|
const nodeVoltages: Record<string, number> = {};
|
|
|
|
|
|
for (const name of result.variableNames) {
|
|
|
|
|
|
const m = name.toLowerCase().match(/^v\((.+)\)$/);
|
|
|
|
|
|
if (!m) continue;
|
|
|
|
|
|
nodeVoltages[m[1]] = result.dcValue(name);
|
|
|
|
|
|
}
|
|
|
|
|
|
return { nodeVoltages, pinNetMap };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
describe('Mixed-mode end-to-end — BJT switch with real ngspice', () => {
|
|
|
|
|
|
it(
|
|
|
|
|
|
'SpiceResolvedPinResolver emits LOW when BJT is saturated (pin 9 HIGH)',
|
|
|
|
|
|
{ timeout: 30_000 },
|
|
|
|
|
|
async () => {
|
|
|
|
|
|
const scheduler = getMixedModeScheduler();
|
|
|
|
|
|
const snapshot = await solveAndSnapshot(bjtSwitchNetlist(5));
|
|
|
|
|
|
|
|
|
|
|
|
// Wire the legacy solver's output into the scheduler.
|
|
|
|
|
|
const store = {
|
|
|
|
|
|
getState: () => snapshot,
|
|
|
|
|
|
subscribe: () => () => {},
|
|
|
|
|
|
};
|
|
|
|
|
|
connectLegacySolverToMixedModeFor(store, scheduler);
|
|
|
|
|
|
|
|
|
|
|
|
// Resolver for "q1:C" — the BJT collector pin.
|
|
|
|
|
|
const resolver = createSpiceResolvedPinResolver(
|
|
|
|
|
|
'q1',
|
|
|
|
|
|
'C',
|
|
|
|
|
|
scheduler,
|
|
|
|
|
|
configFromLogicFamily(FAMILIES.AVR_HC),
|
|
|
|
|
|
);
|
|
|
|
|
|
const cb = vi.fn();
|
|
|
|
|
|
resolver.onChange(cb);
|
|
|
|
|
|
|
|
|
|
|
|
// BJT saturated → Vc ≈ Vce(sat) ≈ 0.1–0.3 V, well below AVR_HC vil=1.0V → LOW.
|
|
|
|
|
|
expect(resolver.getCurrentState()).toBe('LOW');
|
|
|
|
|
|
expect(resolver.getCurrentVoltage()).toBeLessThan(1.0);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
it(
|
|
|
|
|
|
'SpiceResolvedPinResolver emits HIGH when BJT is cut off (pin 9 LOW)',
|
|
|
|
|
|
{ timeout: 30_000 },
|
|
|
|
|
|
async () => {
|
|
|
|
|
|
const scheduler = getMixedModeScheduler();
|
|
|
|
|
|
const snapshot = await solveAndSnapshot(bjtSwitchNetlist(0));
|
|
|
|
|
|
const store = {
|
|
|
|
|
|
getState: () => snapshot,
|
|
|
|
|
|
subscribe: () => () => {},
|
|
|
|
|
|
};
|
|
|
|
|
|
connectLegacySolverToMixedModeFor(store, scheduler);
|
|
|
|
|
|
|
|
|
|
|
|
const resolver = createSpiceResolvedPinResolver(
|
|
|
|
|
|
'q1',
|
|
|
|
|
|
'C',
|
|
|
|
|
|
scheduler,
|
|
|
|
|
|
configFromLogicFamily(FAMILIES.AVR_HC),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// BJT cut off → Vc ≈ 5V (no current through Rc) → above AVR_HC vih=3.0V → HIGH.
|
|
|
|
|
|
expect(resolver.getCurrentVoltage()).toBeGreaterThan(3.0);
|
|
|
|
|
|
expect(resolver.getCurrentState()).toBe('HIGH');
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|