164 lines
6.0 KiB
TypeScript
164 lines
6.0 KiB
TypeScript
/**
|
||
* 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';
|
||
import { runNetlist } from './helpers/testSolver';
|
||
import {
|
||
getMixedModeScheduler,
|
||
__resetMixedModeScheduler,
|
||
} from '../simulation/spice/MixedModeScheduler';
|
||
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));
|
||
|
||
// Publish the snapshot voltages into the scheduler cache so
|
||
// SpiceResolvedPinResolver consumers see them.
|
||
for (const [key, net] of snapshot.pinNetMap) {
|
||
const idx = key.indexOf(':');
|
||
if (idx < 0) continue;
|
||
const componentId = key.slice(0, idx);
|
||
const pinName = key.slice(idx + 1);
|
||
if (net === '0') {
|
||
scheduler.publishVoltage(componentId, pinName, 0);
|
||
continue;
|
||
}
|
||
const v = snapshot.nodeVoltages[net];
|
||
if (typeof v === 'number') scheduler.publishVoltage(componentId, pinName, v);
|
||
}
|
||
|
||
// 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));
|
||
for (const [key, net] of snapshot.pinNetMap) {
|
||
const idx = key.indexOf(':');
|
||
if (idx < 0) continue;
|
||
const componentId = key.slice(0, idx);
|
||
const pinName = key.slice(idx + 1);
|
||
if (net === '0') {
|
||
scheduler.publishVoltage(componentId, pinName, 0);
|
||
continue;
|
||
}
|
||
const v = snapshot.nodeVoltages[net];
|
||
if (typeof v === 'number') scheduler.publishVoltage(componentId, pinName, v);
|
||
}
|
||
|
||
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');
|
||
},
|
||
);
|
||
});
|