466 lines
19 KiB
TypeScript
466 lines
19 KiB
TypeScript
/**
|
|
* NetlistBuilder — turn a Velxio circuit (components + wires + board pin
|
|
* state) into a complete ngspice netlist string.
|
|
*
|
|
* Algorithm (see plan phase_8_velxio_implementation §5):
|
|
* 1. Union-Find on wires to identify nets.
|
|
* 2. Canonicalize known-special nets: GND → "0", VCC/VDD/5V/3V3 → "vcc_rail".
|
|
* 3. Auto-name remaining nets "n0", "n1", ... deterministically.
|
|
* 4. Detect floating nodes (no DC path to 0) → add auto pull-down 100 MΩ.
|
|
* 5. Emit component cards via `componentToSpice`.
|
|
* 6. Emit board GPIO source cards (digital or PWM quasi-static).
|
|
* 7. Emit the Vcc rail source.
|
|
* 8. Append `.model` / `.subckt` cards for every used device.
|
|
* 9. Append analysis card (`.op` / `.tran` / `.ac`).
|
|
* 10. `.end`.
|
|
*/
|
|
import { UnionFind } from './unionFind';
|
|
import { componentToSpice } from './componentToSpice';
|
|
import type { BuildNetlistInput, ComponentForSpice, BoardForSpice, WireForSpice } from './types';
|
|
|
|
const GROUND_PIN_RE = /^(gnd|vss|vee|ground|gnd\.\d+)$/i;
|
|
// Deliberately excludes "V+" / "V-" (which are probe terminals) and
|
|
// "VBB" (non-standard). VCC-like pins on boards are handled via the
|
|
// board.vccPinNames list, not this regex.
|
|
const VCC_PIN_RE = /^(vcc|vdd|vcc_rail|5v|3v3|3\.3v)$/i;
|
|
|
|
/** metadataId prefixes of components that must NOT be auto-canonicalized
|
|
* by the pin-name regex (their pins are just probe labels). */
|
|
function skipCanonicalization(metadataId: string): boolean {
|
|
return metadataId.startsWith('instr-');
|
|
}
|
|
|
|
export interface BuildNetlistResult {
|
|
netlist: string;
|
|
/** "boardId:pinName" → SPICE net name, from the same UF used to build the netlist. */
|
|
pinNetMap: Map<string, string>;
|
|
/**
|
|
* Every SPICE net name in the circuit except canonical "0" (ground).
|
|
* Includes `vcc_rail` plus all auto-named nets (n0, n1, ...). Used
|
|
* by CircuitSimulationService to ask the solver for every node
|
|
* voltage in one shot (vectorsOfInterest).
|
|
*/
|
|
nets: string[];
|
|
/**
|
|
* Every voltage source name in the circuit (without the leading
|
|
* `V` prefix is NOT how ngspice names them — they include the V).
|
|
* Examples: `V_VCC_RAIL`, `V_uno_9`, `V_led1_sense`. Used to
|
|
* request branch currents (`i(v_<name>)`).
|
|
*/
|
|
voltageSources: string[];
|
|
}
|
|
|
|
export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult {
|
|
const { components, wires, boards, analysis, extraCards = [] } = input;
|
|
|
|
// ── 1. Union-Find over wires ─────────────────────────────────────────────
|
|
const uf = new UnionFind();
|
|
const pinKey = (componentId: string, pinName: string) => `${componentId}:${pinName}`;
|
|
|
|
// Seed every pin referenced by a wire (components pins are added on demand).
|
|
// Phase 4: when a wire has `length_cm` set, its endpoints stay in separate
|
|
// nets and a R_wire_<id> card is emitted later (step 7).
|
|
const resistiveWires: typeof wires = [];
|
|
for (const w of wires) {
|
|
const a = pinKey(w.start.componentId, w.start.pinName);
|
|
const b = pinKey(w.end.componentId, w.end.pinName);
|
|
uf.add(a);
|
|
uf.add(b);
|
|
if (w.length_cm !== undefined && w.length_cm > 0) {
|
|
resistiveWires.push(w);
|
|
} else {
|
|
uf.union(a, b);
|
|
}
|
|
}
|
|
|
|
// ── 2. Canonicalize ground / VCC pins ────────────────────────────────────
|
|
for (const board of boards) {
|
|
for (const pinName of board.groundPinNames ?? []) {
|
|
uf.setCanonical(pinKey(board.id, pinName), '0');
|
|
}
|
|
for (const pinName of board.vccPinNames ?? []) {
|
|
uf.setCanonical(pinKey(board.id, pinName), 'vcc_rail');
|
|
}
|
|
}
|
|
for (const comp of components) {
|
|
if (skipCanonicalization(comp.metadataId)) continue;
|
|
for (const pinName of pinsReferencedByWires(comp.id, wires)) {
|
|
if (GROUND_PIN_RE.test(pinName)) {
|
|
uf.setCanonical(pinKey(comp.id, pinName), '0');
|
|
} else if (VCC_PIN_RE.test(pinName)) {
|
|
uf.setCanonical(pinKey(comp.id, pinName), 'vcc_rail');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 3. Auto-name remaining nets deterministically ────────────────────────
|
|
const netNames = assignDeterministicNetNames(uf);
|
|
|
|
// Helper: pin → net name (null if pin isn't in any net)
|
|
function netLookup(componentId: string, pinName: string): string | null {
|
|
const key = pinKey(componentId, pinName);
|
|
if (!uf.has(key)) return null;
|
|
return netNames.get(uf.find(key)) ?? null;
|
|
}
|
|
|
|
// ── 4. Emit component cards ───────────────────────────────────────────────
|
|
const cards: string[] = [];
|
|
const modelLines = new Set<string>();
|
|
const dominantVcc = boards[0]?.vcc ?? 5;
|
|
|
|
for (const comp of components) {
|
|
const localLookup = (pinName: string) => netLookup(comp.id, pinName);
|
|
const emission = componentToSpice(comp, localLookup, { vcc: dominantVcc });
|
|
if (!emission) continue;
|
|
cards.push(...emission.cards);
|
|
for (const m of emission.modelsUsed) modelLines.add(m);
|
|
}
|
|
|
|
// ── 5. Board GPIO sources ─────────────────────────────────────────────────
|
|
for (const board of boards) {
|
|
for (const [pinName, state] of Object.entries(board.pins)) {
|
|
if (state.type === 'input') continue; // don't drive the pin
|
|
const net = netLookup(board.id, pinName);
|
|
if (!net) continue;
|
|
if (net === '0' || net === 'vcc_rail') continue; // already served
|
|
const v = state.type === 'digital' ? state.v : state.duty * board.vcc;
|
|
cards.push(`V_${board.id}_${pinName} ${net} 0 DC ${v}`);
|
|
}
|
|
}
|
|
|
|
// ── 6. Vcc rail source (if any pin referenced it) ─────────────────────────
|
|
if (hasNet(netNames, 'vcc_rail')) {
|
|
cards.unshift(`V_VCC_RAIL vcc_rail 0 DC ${dominantVcc}`);
|
|
}
|
|
|
|
// ── 6.5. Wire resistance (Phase 4) ───────────────────────────────────────
|
|
// Wires marked with length_cm get a resistor between their endpoint nets.
|
|
// R = 0.01 ohm/cm — order-of-magnitude correct for AWG 22 copper hookup
|
|
// wire — enough to show voltage drop on long buses without dominating
|
|
// ordinary circuit behaviour. Emitted before pull-down detection so the
|
|
// resistors count as DC paths between their endpoints.
|
|
for (const w of resistiveWires) {
|
|
const cm = w.length_cm ?? 0;
|
|
if (cm <= 0) continue;
|
|
const ohms = Math.max(0.01, 0.01 * cm);
|
|
const a = netLookup(w.start.componentId, w.start.pinName);
|
|
const b = netLookup(w.end.componentId, w.end.pinName);
|
|
if (!a || !b) continue;
|
|
cards.push(`R_wire_${w.id} ${a} ${b} ${ohms}`);
|
|
}
|
|
|
|
// ── 7. Auto pull-downs for floating nets ─────────────────────────────────
|
|
const floating = detectFloatingNets(netNames, cards);
|
|
for (const net of floating) {
|
|
cards.push(`R_autopull_${net} ${net} 0 100Meg`);
|
|
}
|
|
|
|
// ── 8. Compose netlist ────────────────────────────────────────────────────
|
|
const lines: string[] = [`* Velxio circuit @ ${new Date().toISOString()}`];
|
|
lines.push(...cards);
|
|
lines.push(...modelLines);
|
|
lines.push(...extraCards);
|
|
|
|
switch (analysis.kind) {
|
|
case 'op':
|
|
lines.push('.op');
|
|
break;
|
|
case 'tran':
|
|
lines.push(`.tran ${analysis.step} ${analysis.stop}`);
|
|
break;
|
|
case 'ac': {
|
|
const kind = analysis.type ?? 'dec';
|
|
const points = analysis.points ?? 20;
|
|
const fstart = analysis.fstart ?? 1;
|
|
const fstop = analysis.fstop ?? 1e6;
|
|
lines.push(`.ac ${kind} ${points} ${fstart} ${fstop}`);
|
|
break;
|
|
}
|
|
}
|
|
lines.push('.end');
|
|
|
|
// ── 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 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);
|
|
}
|
|
}
|
|
|
|
// ── 10. Enumerate every net + voltage source for the solve options ───────
|
|
// Distinct, non-ground nets — every node the solver should report.
|
|
const nets = Array.from(new Set(netNames.values())).filter((n) => n !== '0');
|
|
// Voltage sources are any card starting with `V` (uppercase) followed
|
|
// by an underscore or digit. ngspice's case-insensitive match means
|
|
// both `Vname` and `vname` count. We emit only uppercase prefixes
|
|
// from componentToSpice + NetlistBuilder, so this regex is safe.
|
|
//
|
|
// The character class MUST include `-` (hyphen). Component ids in
|
|
// examples and user-drawn circuits commonly contain hyphens
|
|
// (`led-builtin`, `led-red`, auto-generated `led-1717…-abc`), and
|
|
// SPICE itself happily parses identifiers with hyphens. If the regex
|
|
// doesn't accept them it truncates the captured name at the first
|
|
// hyphen ⇒ wrong voltageSources entry ⇒ CircuitSimulationService
|
|
// asks ngspice for the WRONG branch-current vector ⇒ branchCurrents
|
|
// lookup returns undefined ⇒ LED brightness stays at zero even though
|
|
// the circuit conducts correctly. Single-character fix, but it
|
|
// unblocks every hyphenated id across every existing project.
|
|
const voltageSources: string[] = [];
|
|
for (const card of cards) {
|
|
const m = card.match(/^([Vv][_\w-]*)\s/);
|
|
if (m) voltageSources.push(m[1]);
|
|
}
|
|
|
|
return {
|
|
netlist: lines.join('\n'),
|
|
pinNetMap,
|
|
nets,
|
|
voltageSources,
|
|
};
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function pinsReferencedByWires(componentId: string, wires: WireForSpice[]): string[] {
|
|
const pins = new Set<string>();
|
|
for (const w of wires) {
|
|
if (w.start.componentId === componentId) pins.add(w.start.pinName);
|
|
if (w.end.componentId === componentId) pins.add(w.end.pinName);
|
|
}
|
|
return [...pins];
|
|
}
|
|
|
|
function assignDeterministicNetNames(uf: UnionFind): Map<string, string> {
|
|
const reps = [...uf.nets()].sort();
|
|
const out = new Map<string, string>();
|
|
let counter = 0;
|
|
for (const rep of reps) {
|
|
if (rep === '0' || rep === 'vcc_rail') {
|
|
out.set(rep, rep);
|
|
} else {
|
|
// Strip characters ngspice doesn't like from auto-names
|
|
out.set(rep, `n${counter++}`);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function hasNet(netNames: Map<string, string>, name: string): boolean {
|
|
for (const v of netNames.values()) if (v === name) return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Detect nets that lack a DC path to ground.
|
|
*
|
|
* Does a graph walk starting from node "0" and "vcc_rail" (both have a
|
|
* hard-wired source in every circuit that references them), traversing only
|
|
* DC-conducting cards:
|
|
* - resistor (R...)
|
|
* - voltage source (V...) — V-source is a DC short for connectivity
|
|
* - current source (I...)
|
|
* - inductor (L... — treated as DC short)
|
|
* - switch (S...)
|
|
* - behavioral source (B...) — also connects output to ground via its KCL stamp
|
|
* - MNA-controlled source (E..., G..., F..., H...)
|
|
* - subckt instance (X...) — optimistic: assume any X instance exposes DC paths
|
|
*
|
|
* Capacitors (C...) are intentionally NOT conductive in this walk — they are
|
|
* OPEN at DC, so a net connected only via a C to another node is still floating.
|
|
*
|
|
* An older version of this function used a "touched-by-R" heuristic: a net
|
|
* was marked safe if ANY resistor terminal referenced it. That was wrong for
|
|
* topologies like `V0-R1-n0-R2-n1-C-0` where n0 and n1 are chained through
|
|
* R's but neither has a DC path to ground (shipping RC-low-pass example).
|
|
* The graph walk fixes that — it only considers a net safe when a DC path
|
|
* actually traces back to node "0".
|
|
*
|
|
* Returns the set of nets that should receive an auto 100 MΩ pull-down.
|
|
*/
|
|
function detectFloatingNets(netNames: Map<string, string>, cards: string[]): Set<string> {
|
|
const nets = new Set(netNames.values());
|
|
|
|
// Build an undirected adjacency list over DC-conducting elements.
|
|
// For a 2-terminal element the two nets on it become connected.
|
|
// For 3+ terminal (E/G/F/H, S, X) we connect every pair of listed nets —
|
|
// this is conservative (over-connects), which is the safe side for a
|
|
// "does this net have SOME DC path to ground" question.
|
|
const adj = new Map<string, Set<string>>();
|
|
function ensure(n: string) {
|
|
if (!adj.has(n)) adj.set(n, new Set());
|
|
return adj.get(n)!;
|
|
}
|
|
function link(a: string, b: string) {
|
|
if (a === b) return;
|
|
ensure(a).add(b);
|
|
ensure(b).add(a);
|
|
}
|
|
|
|
// Cards that define a DC path between their listed nets. Capacitor ('C')
|
|
// is deliberately excluded.
|
|
const DC_PREFIXES = 'RLVISBEGFHX';
|
|
|
|
for (const line of cards) {
|
|
const prefix = line[0];
|
|
if (DC_PREFIXES.indexOf(prefix) < 0) continue;
|
|
const tokens = line.split(/\s+/);
|
|
// tokens[0] is the element name (e.g. "R_r1", "V_VCC_RAIL"). The nets
|
|
// appear next; stop at the first token that isn't a valid net name.
|
|
const pinNets: string[] = [];
|
|
for (let i = 1; i < tokens.length; i++) {
|
|
const t = tokens[i];
|
|
if (!nets.has(t) && t !== '0' && t !== 'vcc_rail') break;
|
|
pinNets.push(t);
|
|
}
|
|
// Connect every pair of pins — handles both 2-terminal and N-terminal.
|
|
for (let i = 0; i < pinNets.length; i++) {
|
|
for (let j = i + 1; j < pinNets.length; j++) {
|
|
link(pinNets[i], pinNets[j]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// BFS from node "0" (and "vcc_rail", which always has V_VCC_RAIL → 0 edge).
|
|
const reachable = new Set<string>();
|
|
const queue: string[] = ['0'];
|
|
if (adj.has('vcc_rail')) queue.push('vcc_rail');
|
|
while (queue.length) {
|
|
const n = queue.shift()!;
|
|
if (reachable.has(n)) continue;
|
|
reachable.add(n);
|
|
const neigh = adj.get(n);
|
|
if (!neigh) continue;
|
|
for (const m of neigh) if (!reachable.has(m)) queue.push(m);
|
|
}
|
|
|
|
const floating = new Set<string>();
|
|
for (const net of nets) {
|
|
if (net === '0' || net === 'vcc_rail') continue;
|
|
if (!reachable.has(net)) floating.add(net);
|
|
}
|
|
return floating;
|
|
}
|
|
|
|
/**
|
|
* Build a wireId → netName map using the same Union-Find logic as buildNetlist.
|
|
* Lightweight (no SPICE call) — suitable for the overlay to look up voltages.
|
|
*/
|
|
export function buildWireNetMap(
|
|
input: Pick<BuildNetlistInput, 'components' | 'wires' | 'boards'>,
|
|
): Map<string, string> {
|
|
const { wires, boards, components } = input;
|
|
const uf = new UnionFind();
|
|
const pin = (cId: string, pName: string) => `${cId}:${pName}`;
|
|
|
|
for (const w of wires) {
|
|
const a = pin(w.start.componentId, w.start.pinName);
|
|
const b = pin(w.end.componentId, w.end.pinName);
|
|
uf.add(a);
|
|
uf.add(b);
|
|
uf.union(a, b);
|
|
}
|
|
|
|
for (const board of boards) {
|
|
for (const pName of board.groundPinNames ?? []) uf.setCanonical(pin(board.id, pName), '0');
|
|
for (const pName of board.vccPinNames ?? []) uf.setCanonical(pin(board.id, pName), 'vcc_rail');
|
|
}
|
|
for (const comp of components) {
|
|
if (comp.metadataId.startsWith('instr-')) continue;
|
|
for (const pName of pinsReferencedByWires(comp.id, wires)) {
|
|
if (GROUND_PIN_RE.test(pName)) uf.setCanonical(pin(comp.id, pName), '0');
|
|
else if (VCC_PIN_RE.test(pName)) uf.setCanonical(pin(comp.id, pName), 'vcc_rail');
|
|
}
|
|
}
|
|
|
|
const netNames = assignDeterministicNetNames(uf);
|
|
const result = new Map<string, string>();
|
|
for (const w of wires) {
|
|
const key = pin(w.start.componentId, w.start.pinName);
|
|
if (uf.has(key)) {
|
|
const netName = netNames.get(uf.find(key));
|
|
if (netName) result.set(w.id, netName);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Build a map from `"${boardId}:${pinName}"` → SPICE net name for every
|
|
* board pin that participates in the circuit. Used by the ADC injection
|
|
* step in subscribeToStore so it can look up voltages by pin name.
|
|
*/
|
|
export function buildBoardPinNetMap(
|
|
input: Pick<BuildNetlistInput, 'components' | 'wires' | 'boards'>,
|
|
): Map<string, string> {
|
|
const { wires, boards, components } = input;
|
|
const uf = new UnionFind();
|
|
const pin = (cId: string, pName: string) => `${cId}:${pName}`;
|
|
|
|
// Collect board IDs for fast lookup
|
|
const boardIds = new Set(boards.map((b) => b.id));
|
|
|
|
for (const w of wires) {
|
|
const a = pin(w.start.componentId, w.start.pinName);
|
|
const b = pin(w.end.componentId, w.end.pinName);
|
|
uf.add(a);
|
|
uf.add(b);
|
|
uf.union(a, b);
|
|
}
|
|
|
|
// Canonicalize board ground/vcc pins (from boardPinGroups metadata)
|
|
for (const board of boards) {
|
|
for (const pName of board.groundPinNames ?? []) {
|
|
const k = pin(board.id, pName);
|
|
uf.add(k);
|
|
uf.setCanonical(k, '0');
|
|
}
|
|
for (const pName of board.vccPinNames ?? []) {
|
|
const k = pin(board.id, pName);
|
|
uf.add(k);
|
|
uf.setCanonical(k, 'vcc_rail');
|
|
}
|
|
}
|
|
// Canonicalize non-board component GND/VCC pins referenced by wires
|
|
for (const comp of components) {
|
|
if (comp.metadataId.startsWith('instr-')) continue;
|
|
if (boardIds.has(comp.id)) continue; // board handled above
|
|
for (const pName of pinsReferencedByWires(comp.id, wires)) {
|
|
if (GROUND_PIN_RE.test(pName)) uf.setCanonical(pin(comp.id, pName), '0');
|
|
else if (VCC_PIN_RE.test(pName)) uf.setCanonical(pin(comp.id, pName), 'vcc_rail');
|
|
}
|
|
}
|
|
|
|
const netNames = assignDeterministicNetNames(uf);
|
|
const result = new Map<string, string>();
|
|
|
|
// For each board, collect ALL pins that appear in wires (via wire endpoints)
|
|
// plus the explicit groundPinNames/vccPinNames/pins lists.
|
|
for (const board of boards) {
|
|
const wireReferencedPins = pinsReferencedByWires(board.id, wires);
|
|
const allPins = new Set([
|
|
...(board.groundPinNames ?? []),
|
|
...(board.vccPinNames ?? []),
|
|
...Object.keys(board.pins ?? {}),
|
|
...wireReferencedPins, // ← the pins that actually exist in the UF
|
|
]);
|
|
for (const pName of allPins) {
|
|
const k = pin(board.id, pName);
|
|
if (uf.has(k)) {
|
|
const netName = netNames.get(uf.find(k));
|
|
if (netName) result.set(k, netName);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Re-export types for callers. */
|
|
export type { BuildNetlistInput, ComponentForSpice, BoardForSpice, WireForSpice } from './types';
|