velxio/frontend/src/__tests__/mixed-mode-bjt-switch-integ...

164 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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.10.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');
},
);
});