/** * 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; /** * 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_)`). */ 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_ 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(); 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(); 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(); 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 { const reps = [...uf.nets()].sort(); const out = new Map(); 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, 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, cards: string[]): Set { 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>(); 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(); 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(); 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, ): Map { 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(); 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, ): Map { 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(); // 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';