From 77c85eefacfa8d0b5aa82cb809d2cd80ca306d71 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Fri, 5 Jun 2026 21:01:05 -0300 Subject: [PATCH] feat(digital-gate-engine): Phase 3 core - digital/analog boundary handoff buildMixedNetwork evaluates the gate (digital) side of a MIXED circuit on the settle kernel and exposes the boundary with the analog (ngspice) domain. Unlike buildDigitalNetwork it does not bail on non-primitive components - those are the analog side; their pins mark the nets they touch as boundary. Exposes boundaryNets, readBoundary(net) (digital->analog: the gate-driven level to seed an ngspice voltage source) and setBoundaryInput(net, level) (analog->digital: ngspice's solved+thresholded level, which re-evaluates downstream gates). Test digitalgate-mixed-boundary (4): the boundary nets are exactly the digital/analog bridges; both directions track; a digital->analog->digital coupler loop converges. No ngspice needed - the analog side is supplied by the test. Wiring the handoff to the live ngspice netlist (0/Vcc sources + threshold + settle<->solve iteration) is the remaining step; it needs the running solver (the node loader is broken by a pre-existing path bug) and a mixed example. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../digitalgate-mixed-boundary.test.ts | 84 ++++++++++++ .../simulation/digital/digitalGateEngine.ts | 120 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 frontend/src/__tests__/digitalgate-mixed-boundary.test.ts diff --git a/frontend/src/__tests__/digitalgate-mixed-boundary.test.ts b/frontend/src/__tests__/digitalgate-mixed-boundary.test.ts new file mode 100644 index 00000000..6b60f5ad --- /dev/null +++ b/frontend/src/__tests__/digitalgate-mixed-boundary.test.ts @@ -0,0 +1,84 @@ +/** + * digital-gate-engine Phase 3 (core) — the digital/analog boundary handoff. + * + * A mixed circuit: a switch drives a NOT gate whose output feeds an ANALOG + * device (a BJT, here a stand-in), and another analog node feeds a second NOT + * gate. buildMixedNetwork evaluates the gate (digital) side on the settle kernel + * and exposes the boundary nets where the two motors hand off: + * - digital -> analog: readBoundary() gives the gate-driven level to seed an + * ngspice voltage source. + * - analog -> digital: setBoundaryInput() pushes ngspice's solved+thresholded + * node level onto the net so downstream gates re-evaluate. + * + * Verifiable with NO ngspice (the analog side is supplied here). Wiring it to + * the live solver is the follow-up — the node ngspice loader is broken by a + * pre-existing path bug, so that step is browser-verified. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetBusNets } from '../simulation/customChips/busNets'; +import { buildMixedNetwork, type DigitalComponent, type DigitalWire } from '../simulation/digital/digitalGateEngine'; + +beforeEach(() => resetBusNets()); + +// switch -> NOT(g1) -> [BJT a1] -> NOT(g2) -> out +const components: DigitalComponent[] = [ + { id: 'src', metadataId: 'signal-generator' }, + { id: 'sw1', metadataId: 'slide-switch', properties: { value: 0 } }, + { id: 'g1', metadataId: 'logic-gate-not' }, + { id: 'a1', metadataId: 'bjt-npn' }, // analog (non-primitive) + { id: 'g2', metadataId: 'logic-gate-not' }, +]; +const W = (a: string, ap: string, b: string, bp: string): DigitalWire => ({ start: { componentId: a, pinName: ap }, end: { componentId: b, pinName: bp } }); +const wires: DigitalWire[] = [ + W('src', 'SIG', 'sw1', '1'), + W('sw1', '2', 'g1', 'A'), + W('g1', 'Y', 'a1', 'B'), // boundary OUT (digital drives, analog reads) + W('a1', 'C', 'g2', 'A'), // boundary IN (analog drives, digital reads) +]; + +describe('digital-gate-engine Phase 3 — mixed boundary', () => { + it('identifies exactly the two nets that bridge digital and analog', () => { + const net = buildMixedNetwork(components, wires); + expect(net.ok).toBe(true); + const outBoundary = net.netOf('g1', 'Y'); + const inBoundary = net.netOf('a1', 'C'); + expect(outBoundary).toBeDefined(); + expect(inBoundary).toBeDefined(); + expect(new Set(net.boundaryNets)).toEqual(new Set([outBoundary, inBoundary])); + }); + + it('digital -> analog: the gate-driven boundary level tracks the switch', () => { + const net = buildMixedNetwork(components, wires); + const out = net.netOf('g1', 'Y')!; + net.setSwitch('sw1', 1); // NOT(1) = 0 + expect(net.readBoundary(out), 'sw=1 -> NOT -> 0').toBe(0); + net.setSwitch('sw1', 0); // NOT(0) = 1 + expect(net.readBoundary(out), 'sw=0 -> NOT -> 1').toBe(1); + }); + + it('analog -> digital: pushing a boundary level re-evaluates the gate', () => { + const net = buildMixedNetwork(components, wires); + const inNet = net.netOf('a1', 'C')!; + const out = net.netOf('g2', 'Y')!; + net.setBoundaryInput(inNet, 1); // NOT(1) = 0 + expect(net.readNet(out), 'analog 1 -> NOT -> 0').toBe(0); + net.setBoundaryInput(inNet, 0); // NOT(0) = 1 + expect(net.readNet(out), 'analog 0 -> NOT -> 1').toBe(1); + }); + + it('a digital->analog->digital chain converges like a coupler iteration', () => { + // Coupler loop: read the digital-driven boundary, "solve" the analog (here an + // ideal wire: collector follows base), push it back, read the final output. + const net = buildMixedNetwork(components, wires); + const outB = net.netOf('g1', 'Y')!; + const inB = net.netOf('a1', 'C')!; + const finalOut = net.netOf('g2', 'Y')!; + for (const sw of [0, 1, 0, 1] as const) { + net.setSwitch('sw1', sw); + const analogIn = net.readBoundary(outB); // g1 = NOT(sw) + net.setBoundaryInput(inB, analogIn); // ideal analog: C = B + // g2 = NOT(analogIn) = NOT(NOT(sw)) = sw + expect(net.readNet(finalOut), `chain sw=${sw}`).toBe(sw); + } + }); +}); diff --git a/frontend/src/simulation/digital/digitalGateEngine.ts b/frontend/src/simulation/digital/digitalGateEngine.ts index 1c3a31cf..e9c693a3 100644 --- a/frontend/src/simulation/digital/digitalGateEngine.ts +++ b/frontend/src/simulation/digital/digitalGateEngine.ts @@ -298,3 +298,123 @@ export function buildDigitalNetwork( ledIds, }; } + +// ── Phase 3: mixed digital/analog boundary ────────────────────────────────── + +export interface MixedNetwork { + ok: boolean; + pinManager: PinManager; + netOf(componentId: string, pin: string): number | undefined; + readNet(net: number): 0 | 1; + setSwitch(switchId: string, value: 0 | 1): void; + /** Nets that bridge a digital pin (gate/switch) and an analog pin. These are + * where the two motors hand off. */ + boundaryNets: number[]; + /** Digital-side level of a boundary net — what to drive into ngspice as a + * 0 / Vcc voltage source on that node (digital -> analog). */ + readBoundary(net: number): 0 | 1; + /** Push an analog-side level (ngspice's threshold-converted node voltage) onto + * a boundary net so the gates downstream re-evaluate (analog -> digital). */ + setBoundaryInput(net: number, level: 0 | 1): void; +} + +/** + * Build the digital half of a MIXED circuit and expose its boundary with the + * analog (ngspice) domain. Unlike buildDigitalNetwork it does NOT bail on + * non-primitive components — those are the analog side; their pins simply mark + * the nets they touch as boundary. The caller (the ngspice coupler) reads the + * digital-driven boundary nets to seed voltage sources, and pushes ngspice's + * solved+thresholded boundary voltages back via setBoundaryInput, iterating to a + * fixed point. Settle on the digital side is the same exact kernel as the + * all-digital path. + * + * Phase 3 core: the boundary handoff + digital settle, verifiable headlessly + * (the analog side is supplied by the test). Wiring it to the live ngspice + * netlist is the follow-up (needs the browser solver; the node loader is broken + * by a pre-existing path bug). + */ +export function buildMixedNetwork( + components: DigitalComponent[], + wires: DigitalWire[], + pinManager?: PinManager, +): MixedNetwork { + const pm = pinManager ?? new PinManager(); + const byId = new Map(components.map((c) => [c.id, c])); + const uf = new UnionFind(); + for (const w of wires) uf.union(epKey(w.start.componentId, w.start.pinName), epKey(w.end.componentId, w.end.pinName)); + const pinNet = (id: string, pin: string) => uf.find(epKey(id, pin)); + + const gndRoots = new Set(); + const railRoots = new Set(); + for (const c of components) { + if (isPower(kindOf(c))) { gndRoots.add(pinNet(c.id, 'GND')); railRoots.add(pinNet(c.id, 'SIG')); } + } + const isGnd = (r: string) => gndRoots.has(r); + const isRail = (r: string) => railRoots.has(r); + + const keyOf = new Map(); + let nextKey = 1; + const netKey = (id: string, pin: string): number => { + const root = pinNet(id, pin); + let k = keyOf.get(root); + if (k === undefined) { k = nextKey++; keyOf.set(root, k); } + return k; + }; + const netOf = (id: string, pin: string): number | undefined => (byId.has(id) ? netKey(id, pin) : undefined); + + // Classify each net root by who touches it, walking wire endpoints. + const hasDigital = new Set(); + const hasAnalog = new Set(); + const mark = (compId: string, pin: string) => { + const c = byId.get(compId); + if (!c) return; + const k = kindOf(c); + const root = pinNet(compId, pin); + if (isGate(k) || isSwitch(k) || isLed(k)) hasDigital.add(root); + else if (!isPower(k) && !isResistor(k)) hasAnalog.add(root); // non-primitive = analog + }; + for (const w of wires) { mark(w.start.componentId, w.start.pinName); mark(w.end.componentId, w.end.pinName); } + + // Static rail/gnd + switches + gates (same model as the all-digital path). + for (const c of components) { + if (isPower(kindOf(c))) { + setBusDrive(pm, netKey(c.id, 'SIG'), `${c.id}::SIG`, STRONG(1)); + setBusDrive(pm, netKey(c.id, 'GND'), `${c.id}::GND`, STRONG(0)); + } + } + const switchState = new Map(); + const driveSwitch = (c: DigitalComponent) => { + const closed = switchState.get(c.id) ?? (Number(c.properties?.value) === 1 ? 1 : 0); + const n1 = netKey(c.id, '1'), n2 = netKey(c.id, '2'); + const [src, dst] = isRail(pinNet(c.id, '1')) ? [n1, n2] : [n2, n1]; + if (closed) setBusDrive(pm, dst, `${c.id}::pass`, STRONG(pm.getPinState(src) ? 1 : 0)); + else setBusDrive(pm, dst, `${c.id}::pass`, { value: 0, strength: Strength.HIGHZ }); + }; + for (const c of components) if (isSwitch(kindOf(c))) driveSwitch(c); + for (const c of components) { + if (!isGate(kindOf(c))) continue; + const spec = parseGate(kindOf(c)); + if (!spec) continue; + const inNets = spec.inputs.map((p) => netKey(c.id, p)); + const outNet = netKey(c.id, 'Y'); + const st = inNets.map((n) => pm.getPinState(n)); + const update = () => setBusDrive(pm, outNet, `${c.id}::Y`, STRONG(spec.fn(st) ? 1 : 0)); + inNets.forEach((n, i) => pm.onPinChange(n, (_p, s) => { st[i] = s; update(); })); + update(); + } + for (const c of components) if (isSwitch(kindOf(c))) driveSwitch(c); + + const boundaryRoots = [...hasDigital].filter((r) => hasAnalog.has(r)); + const boundaryNets = boundaryRoots.map((root) => { const k = keyOf.get(root); return k ?? (keyOf.set(root, nextKey).get(root), nextKey++); }); + + return { + ok: true, + pinManager: pm, + netOf, + readNet: (net) => (pm.getPinState(net) ? 1 : 0), + setSwitch: (switchId, value) => { switchState.set(switchId, value); const c = byId.get(switchId); if (c) driveSwitch(c); }, + boundaryNets, + readBoundary: (net) => (pm.getPinState(net) ? 1 : 0), + setBoundaryInput: (net, level) => setBusDrive(pm, net, `analog::${net}`, STRONG(level)), + }; +}