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) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-06-05 21:01:05 -03:00
parent 4f4ee0bf60
commit 77c85eefac
2 changed files with 204 additions and 0 deletions

View File

@ -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);
}
});
});

View File

@ -298,3 +298,123 @@ export function buildDigitalNetwork(
ledIds, 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<string>();
const railRoots = new Set<string>();
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<string, number>();
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<string>();
const hasAnalog = new Set<string>();
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<string, 0 | 1>();
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)),
};
}