feat(sim): include component pins in NetlistBuilder.pinNetMap + e2e BJT-switch test

Fixes the gap that Phase 1b step 4 surfaced: the legacy pinNetMap was
built from board endpoints only, so the bridge from legacy solver to
MixedModeScheduler had nothing to publish for component pins like
"q1:C" — every SpiceResolvedPinResolver was stuck on FLOATING.

Now pinNetMap contains an entry for every wire endpoint, board or
component. Backwards compatible: legacy ADC injection only ever looked
up `boardId:pinName` keys, which are unchanged.

The new e2e integration test wires up real ngspice (eecircuit-engine,
no mock):
  Arduino pin 9 → 1k → 2N2222 base; collector via 220 to 5V
  - pin 9 HIGH → BJT saturated → Vc ≈ 0.05V → resolver emits LOW
  - pin 9 LOW  → BJT cut off    → Vc ≈ 5V    → resolver emits HIGH

Validated against the AVR_HC logic family (Phase 3). With 216 tests
green across 25 files, the Phase 1b pipeline is now demonstrably
correct end-to-end against a real SPICE solver, not just mocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-15 17:22:36 +02:00
parent da07345bc0
commit 8f49665cdf
2 changed files with 160 additions and 12 deletions

View File

@ -0,0 +1,149 @@
/**
* 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 '../simulation/spice/SpiceEngine';
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.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));
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');
},
);
});

View File

@ -142,19 +142,18 @@ export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult {
}
lines.push('.end');
// ── 9. Build board pin → net map from the same UF ─────────────────────────
// Must use the same `uf` and `netNames` so net names match what ngspice sees.
// ── 9. Build (component|board) pin → net map from the same UF ────────────
// Every wire endpoint is in the UF. Including component-pin entries (not
// just board pins) lets the MixedModeScheduler bridge route SPICE voltages
// to component subscribers downstream of active devices. Legacy ADC
// injection still works — it only looks up board-prefixed keys.
const pinNetMap = new Map<string, string>();
for (const board of boards) {
// All wire endpoints that belong to this board are already in the UF.
for (const w of wires) {
for (const endpoint of [w.start, w.end]) {
if (endpoint.componentId !== board.id) continue;
const key = pinKey(board.id, endpoint.pinName);
if (!uf.has(key)) continue;
const netName = netNames.get(uf.find(key));
if (netName) pinNetMap.set(key, netName);
}
for (const w of wires) {
for (const endpoint of [w.start, w.end]) {
const key = pinKey(endpoint.componentId, endpoint.pinName);
if (!uf.has(key)) continue;
const netName = netNames.get(uf.find(key));
if (netName) pinNetMap.set(key, netName);
}
}