diff --git a/frontend/src/__tests__/mixed-mode-bjt-switch-integration.test.ts b/frontend/src/__tests__/mixed-mode-bjt-switch-integration.test.ts new file mode 100644 index 00000000..d9ca3447 --- /dev/null +++ b/frontend/src/__tests__/mixed-mode-bjt-switch-integration.test.ts @@ -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; + pinNetMap: Map; +}> { + const { netlist, pinNetMap } = buildNetlist(input); + const result = await runNetlist(netlist); + const nodeVoltages: Record = {}; + 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'); + }, + ); +}); diff --git a/frontend/src/simulation/spice/NetlistBuilder.ts b/frontend/src/simulation/spice/NetlistBuilder.ts index 14d99007..6ad96c2b 100644 --- a/frontend/src/simulation/spice/NetlistBuilder.ts +++ b/frontend/src/simulation/spice/NetlistBuilder.ts @@ -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(); - 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); } }