1294 lines
51 KiB
TypeScript
1294 lines
51 KiB
TypeScript
/**
|
||
* Map Velxio components (identified by `metadataId`) to SPICE netlist cards.
|
||
*
|
||
* Public contract:
|
||
* componentToSpice(comp, netLookup, context)
|
||
* → { cards: string[], modelsUsed: Set<string> }
|
||
*
|
||
* `netLookup(pinName)` returns the canonical net name for that pin. Callers
|
||
* (the NetlistBuilder) are responsible for feeding in a lookup that already
|
||
* knows about Union-Find / canonicalization.
|
||
*
|
||
* Adding new component mappings: append an entry to `MAPPERS`.
|
||
*/
|
||
|
||
import type { ComponentForSpice } from './types';
|
||
import { parseValueWithUnits } from './valueParser';
|
||
import { LM358_SUBCKT } from './models/lm358Subckt';
|
||
import { getChipDrivenPins } from '../customChips/chipPinDrives';
|
||
|
||
export interface SpiceEmission {
|
||
/** One or more netlist lines (without trailing newline). */
|
||
cards: string[];
|
||
/** Model or subckt names this emission depends on (so the builder adds `.model` later). */
|
||
modelsUsed: Set<string>;
|
||
}
|
||
|
||
export type NetLookup = (pinName: string) => string | null;
|
||
|
||
export interface MapperContext {
|
||
/** Supply voltage in effect (V). Boards set this; ground is 0. */
|
||
vcc: number;
|
||
}
|
||
|
||
type Mapper = (
|
||
comp: ComponentForSpice,
|
||
netLookup: NetLookup,
|
||
ctx: MapperContext,
|
||
) => SpiceEmission | null;
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
function twoPin(
|
||
comp: ComponentForSpice,
|
||
netLookup: NetLookup,
|
||
pinA: string,
|
||
pinB: string,
|
||
): [string, string] | null {
|
||
const a = netLookup(pinA);
|
||
const b = netLookup(pinB);
|
||
if (!a || !b) return null;
|
||
return [a, b];
|
||
}
|
||
|
||
function emitResistor(
|
||
comp: ComponentForSpice,
|
||
pins: [string, string],
|
||
value: number,
|
||
): SpiceEmission {
|
||
return {
|
||
cards: [`R_${comp.id} ${pins[0]} ${pins[1]} ${value}`],
|
||
modelsUsed: new Set(),
|
||
};
|
||
}
|
||
|
||
function emitCapacitor(
|
||
comp: ComponentForSpice,
|
||
pins: [string, string],
|
||
value: number,
|
||
ic = 0,
|
||
): SpiceEmission {
|
||
return {
|
||
cards: [`C_${comp.id} ${pins[0]} ${pins[1]} ${value} IC=${ic}`],
|
||
modelsUsed: new Set(),
|
||
};
|
||
}
|
||
|
||
function emitInductor(
|
||
comp: ComponentForSpice,
|
||
pins: [string, string],
|
||
value: number,
|
||
): SpiceEmission {
|
||
return {
|
||
cards: [`L_${comp.id} ${pins[0]} ${pins[1]} ${value}`],
|
||
modelsUsed: new Set(),
|
||
};
|
||
}
|
||
|
||
// ── LED colour → Shockley params (tuned so V_f at 10 mA matches datasheet) ──
|
||
const LED_MODELS: Record<string, { name: string; Is: string; n: string }> = {
|
||
red: { name: 'LED_RED', Is: '1e-20', n: '1.7' },
|
||
green: { name: 'LED_GREEN', Is: '1e-22', n: '1.9' },
|
||
yellow: { name: 'LED_YELLOW', Is: '1e-21', n: '1.8' },
|
||
blue: { name: 'LED_BLUE', Is: '1e-28', n: '2.0' },
|
||
white: { name: 'LED_WHITE', Is: '1e-28', n: '2.0' },
|
||
};
|
||
|
||
// ── NTC β-model ────────────────────────────────────────────────────────────
|
||
function ntcResistance(Tc: number, R0 = 10_000, T0 = 298.15, beta = 3950): number {
|
||
const T = Tc + 273.15;
|
||
return R0 * Math.exp(beta * (1 / T - 1 / T0));
|
||
}
|
||
|
||
// ── Mappers (one per metadataId) ───────────────────────────────────────────
|
||
|
||
const MAPPERS: Record<string, Mapper> = {
|
||
// Custom chip — emit a DC voltage source for every pin the chip's WASM is
|
||
// currently driving as an output (recorded in customChips/chipPinDrives).
|
||
// This makes the chip a first-class SPICE source on its nets, so LEDs,
|
||
// resistors and analog parts wired straight to a chip output pin are driven
|
||
// by the engine — exactly like a board GPIO. Chip pins wired to a real board
|
||
// pin resolve to that board's source instead and aren't recorded here.
|
||
'custom-chip': (comp, netLookup) => {
|
||
const driven = getChipDrivenPins(comp.id);
|
||
if (driven.length === 0) return null;
|
||
const cid = String(comp.id).replace(/[^A-Za-z0-9_]/g, '_');
|
||
const cards: string[] = [];
|
||
for (const { pin, voltage } of driven) {
|
||
const net = netLookup(pin);
|
||
if (!net || net === '0' || net === 'vcc_rail') continue;
|
||
const pid = String(pin).replace(/[^A-Za-z0-9_]/g, '_');
|
||
cards.push(`V_${cid}_${pid} ${net} 0 DC ${voltage}`);
|
||
}
|
||
if (cards.length === 0) return null;
|
||
return { cards, modelsUsed: new Set() };
|
||
},
|
||
|
||
// Passive — Velxio existing parts
|
||
resistor: (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, '1', '2');
|
||
if (!pins) return null;
|
||
const ohms = parseValueWithUnits(comp.properties.value, 1000);
|
||
return emitResistor(comp, pins, ohms);
|
||
},
|
||
'resistor-us': (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, '1', '2');
|
||
if (!pins) return null;
|
||
const ohms = parseValueWithUnits(comp.properties.value, 1000);
|
||
return emitResistor(comp, pins, ohms);
|
||
},
|
||
capacitor: (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, '1', '2');
|
||
if (!pins) return null;
|
||
const farads = parseValueWithUnits(comp.properties.value, 1e-6);
|
||
return emitCapacitor(comp, pins, farads);
|
||
},
|
||
// Polarized aluminum-can cap. Pin names match the visual SVG ('+' / '−');
|
||
// SPICE itself is bidirectional so the order doesn't matter for the solve,
|
||
// but the names let us validate user wiring later.
|
||
'capacitor-electrolytic': (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, '+', '−');
|
||
if (!pins) return null;
|
||
const farads = parseValueWithUnits(comp.properties.value, 1e-6);
|
||
return emitCapacitor(comp, pins, farads);
|
||
},
|
||
inductor: (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, '1', '2');
|
||
if (!pins) return null;
|
||
const henries = parseValueWithUnits(comp.properties.value, 1e-3);
|
||
return emitInductor(comp, pins, henries);
|
||
},
|
||
|
||
// Passive (new generic parts — Phase 8.4 seeds)
|
||
'analog-resistor': (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, 'A', 'B');
|
||
if (!pins) return null;
|
||
const ohms = parseValueWithUnits(comp.properties.value, 1000);
|
||
return emitResistor(comp, pins, ohms);
|
||
},
|
||
'analog-capacitor': (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, 'A', 'B');
|
||
if (!pins) return null;
|
||
const farads = parseValueWithUnits(comp.properties.value, 1e-6);
|
||
return emitCapacitor(comp, pins, farads);
|
||
},
|
||
'analog-inductor': (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, 'A', 'B');
|
||
if (!pins) return null;
|
||
const henries = parseValueWithUnits(comp.properties.value, 1e-3);
|
||
return emitInductor(comp, pins, henries);
|
||
},
|
||
|
||
// LEDs (colored)
|
||
// A zero-volt sense source is inserted in series so ngspice emits
|
||
// `i(v_<id>_sense)` in the branch currents (diodes on their own produce no
|
||
// `i(...)` vector). BasicParts.ts reads that key to drive `el.brightness`.
|
||
led: (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, 'A', 'C');
|
||
if (!pins) return null;
|
||
const color = String(comp.properties.color ?? 'red').toLowerCase();
|
||
const model = LED_MODELS[color] ?? LED_MODELS.red;
|
||
const midNet = `${comp.id}_sense_mid`;
|
||
return {
|
||
cards: [
|
||
`V_${comp.id}_sense ${pins[0]} ${midNet} DC 0`,
|
||
`D_${comp.id} ${midNet} ${pins[1]} ${model.name}`,
|
||
],
|
||
modelsUsed: new Set([`.model ${model.name} D(Is=${model.Is} N=${model.n})`]),
|
||
};
|
||
},
|
||
|
||
// Generic diode
|
||
diode: (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, 'A', 'C');
|
||
if (!pins) return null;
|
||
return {
|
||
cards: [`D_${comp.id} ${pins[0]} ${pins[1]} DGENERIC`],
|
||
modelsUsed: new Set(['.model DGENERIC D(Is=1e-14 N=1)']),
|
||
};
|
||
},
|
||
'diode-1n4148': (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, 'A', 'C');
|
||
if (!pins) return null;
|
||
return {
|
||
cards: [`D_${comp.id} ${pins[0]} ${pins[1]} D1N4148`],
|
||
// Source: LTSpice-Libraries / Linear Tech standard.dio (OnSemi origin).
|
||
// Adds reverse-recovery time (tt=20n) so AC/transient behaviour is
|
||
// correct at MHz speeds. Original Bv/Ibv preserved for breakdown.
|
||
modelsUsed: new Set([
|
||
'.model D1N4148 D(Is=2.52n Rs=.568 N=1.752 Cjo=4p M=.4 tt=20n Bv=100 Ibv=0.1u)',
|
||
]),
|
||
};
|
||
},
|
||
'diode-1n4007': (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, 'A', 'C');
|
||
if (!pins) return null;
|
||
return {
|
||
cards: [`D_${comp.id} ${pins[0]} ${pins[1]} D1N4007`],
|
||
modelsUsed: new Set(['.model D1N4007 D(Is=76.9n N=1.45 Rs=0.0342 Ikf=2.34 Bv=1000 Ibv=5u)']),
|
||
};
|
||
},
|
||
'zener-1n4733': (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, 'A', 'C');
|
||
if (!pins) return null;
|
||
return {
|
||
cards: [`D_${comp.id} ${pins[0]} ${pins[1]} D1N4733`],
|
||
modelsUsed: new Set(['.model D1N4733 D(Is=1n N=1 Rs=5 Bv=5.1 Ibv=50m)']),
|
||
};
|
||
},
|
||
|
||
// BJT real part numbers — NPN. Phase 2: full Gummel-Poon parameters sourced
|
||
// from LTSpice-Libraries (Linear Tech standard.bjt). These include junction
|
||
// capacitances (CJC/CJE) and transit times (TF/TR/ITF/VTF/XTF) so the parts
|
||
// model AC and switching behaviour correctly, not just DC saturation.
|
||
'bjt-2n2222': (comp, netLookup) => {
|
||
const c = netLookup('C');
|
||
const b = netLookup('B');
|
||
const e = netLookup('E');
|
||
if (!c || !b || !e) return null;
|
||
return {
|
||
cards: [`Q_${comp.id} ${c} ${b} ${e} Q2N2222`],
|
||
modelsUsed: new Set([
|
||
'.model Q2N2222 NPN(IS=1E-14 VAF=100 BF=200 IKF=0.3 XTB=1.5 BR=3 CJC=8E-12 CJE=25E-12 TR=100E-9 TF=400E-12 ITF=1 VTF=2 XTF=3 RB=10 RC=.3 RE=.2)',
|
||
]),
|
||
};
|
||
},
|
||
'bjt-bc547': (comp, netLookup) => {
|
||
const c = netLookup('C');
|
||
const b = netLookup('B');
|
||
const e = netLookup('E');
|
||
if (!c || !b || !e) return null;
|
||
return {
|
||
cards: [`Q_${comp.id} ${c} ${b} ${e} QBC547`],
|
||
modelsUsed: new Set([
|
||
'.model QBC547 NPN(IS=2.39E-14 NF=1.008 ISE=3.545E-15 NE=1.541 BF=294.3 IKF=0.1357 VAF=63.2 NR=1.004 ISC=6.272E-14 NC=1.243 BR=7.946 IKR=0.1144 VAR=25.9 RB=1 IRB=1u RBM=1 RE=0.4683 RC=0.85 XTB=0 EG=1.11 XTI=3 CJE=1.358E-11 VJE=0.65 MJE=0.3279 TF=4.391E-10 XTF=120 VTF=2.643 ITF=0.7495 CJC=3.728E-12 VJC=0.3997 MJC=0.2955 XCJC=0.6193 TR=1E-32)',
|
||
]),
|
||
};
|
||
},
|
||
'bjt-2n3055': (comp, netLookup) => {
|
||
const c = netLookup('C');
|
||
const b = netLookup('B');
|
||
const e = netLookup('E');
|
||
if (!c || !b || !e) return null;
|
||
return {
|
||
cards: [`Q_${comp.id} ${c} ${b} ${e} Q2N3055`],
|
||
modelsUsed: new Set([
|
||
'.model Q2N3055 NPN(BF=73 BR=2.66 RB=.81 RC=.0856 RE=.000856 CJC=1000P PC=.75 MC=.33 TR=.5703U IS=2.37E-8 CJE=415P PE=.75 ME=.5 TF=99.52N NE=1.26 IK=1)',
|
||
]),
|
||
};
|
||
},
|
||
|
||
// BJT — PNP
|
||
'bjt-2n3906': (comp, netLookup) => {
|
||
const c = netLookup('C');
|
||
const b = netLookup('B');
|
||
const e = netLookup('E');
|
||
if (!c || !b || !e) return null;
|
||
return {
|
||
cards: [`Q_${comp.id} ${c} ${b} ${e} Q2N3906`],
|
||
modelsUsed: new Set([
|
||
'.model Q2N3906 PNP(IS=1E-14 VAF=100 BF=200 IKF=0.4 XTB=1.5 BR=4 CJC=4.5E-12 CJE=10E-12 RB=20 RC=0.1 RE=0.1 TR=250E-9 TF=350E-12 ITF=1 VTF=2 XTF=3)',
|
||
]),
|
||
};
|
||
},
|
||
'bjt-bc557': (comp, netLookup) => {
|
||
const c = netLookup('C');
|
||
const b = netLookup('B');
|
||
const e = netLookup('E');
|
||
if (!c || !b || !e) return null;
|
||
return {
|
||
cards: [`Q_${comp.id} ${c} ${b} ${e} QBC557`],
|
||
modelsUsed: new Set([
|
||
'.model QBC557 PNP(IS=3.83E-14 NF=1.008 ISE=1.22E-14 NE=1.528 BF=344.4 IKF=0.08039 VAF=21.11 NR=1.005 ISC=2.85E-13 NC=1.28 BR=14.84 IKR=0.047 VAR=32.02 RB=1 IRB=1u RBM=1 RE=0.6202 RC=0.5713 XTB=0 EG=1.11 XTI=3 CJE=1.23E-11 VJE=0.6106 MJE=0.378 TF=5.60E-10 XTF=3.414 VTF=5.23 ITF=0.1483 CJC=1.08E-11 VJC=0.1022 MJC=0.3563 XCJC=0.6288 TR=1E-32)',
|
||
]),
|
||
};
|
||
},
|
||
|
||
// MOSFETs — NMOS. Phase 2.1: VDMOS macro-models from LTSpice-Libraries.
|
||
// VDMOS is 3-terminal (D G S) — no body, no L/W — and uses physical
|
||
// parameters (Ron, Vto, gate capacitances, Qg) instead of Level=1's
|
||
// process-level Kp/W/L. Models reflect manufacturer datasheets so the
|
||
// simulator now sees real Ron, switching speed, and gate-charge effects.
|
||
//
|
||
// Library coverage gaps: IRF540 and IRF9540 are not in LTSpice's
|
||
// `standard.mos`. IRF530 (100 V N-MOS, same series) substitutes for IRF540.
|
||
// IRF9640 (200 V P-MOS) substitutes for IRF9540 — both close enough that
|
||
// a casual circuit drawn around "IRF540" still behaves correctly.
|
||
'mosfet-2n7000': (comp, netLookup) => {
|
||
const d = netLookup('D');
|
||
const g = netLookup('G');
|
||
const s = netLookup('S');
|
||
if (!d || !g || !s) return null;
|
||
return {
|
||
cards: [`M_${comp.id} ${d} ${g} ${s} M2N7000`],
|
||
modelsUsed: new Set([
|
||
'.model M2N7000 VDMOS(Rg=3 Vto=1.6 Rd=0 Rs=.75 Rb=.14 Kp=.17 mtriode=1.25 Cgdmax=80p Cgdmin=12p Cgs=50p Cjo=50p Is=.04p Vds=60 Ron=2 Qg=1.5n)',
|
||
]),
|
||
};
|
||
},
|
||
'mosfet-irf540': (comp, netLookup) => {
|
||
const d = netLookup('D');
|
||
const g = netLookup('G');
|
||
const s = netLookup('S');
|
||
if (!d || !g || !s) return null;
|
||
return {
|
||
cards: [`M_${comp.id} ${d} ${g} ${s} MIRF540`],
|
||
modelsUsed: new Set([
|
||
// Substitute: LTSpice ships IRF530 (same TO-220, 100 V, slightly
|
||
// smaller die). Close enough for the canvas.
|
||
'.model MIRF540 VDMOS(Rg=3 Vto=4 Rd=50m Rs=12m Rb=60m Kp=5 lambda=.01 Cgdmax=1n Cgdmin=.26n Cgs=.2n Cjo=.4n Is=52p Vds=100 Ron=160m Qg=26n)',
|
||
]),
|
||
};
|
||
},
|
||
|
||
// MOSFETs — PMOS (P-channel: Vto is negative, V_GS < Vto turns device ON)
|
||
'mosfet-irf9540': (comp, netLookup) => {
|
||
const d = netLookup('D');
|
||
const g = netLookup('G');
|
||
const s = netLookup('S');
|
||
if (!d || !g || !s) return null;
|
||
return {
|
||
cards: [`M_${comp.id} ${d} ${g} ${s} MIRF9540`],
|
||
modelsUsed: new Set([
|
||
// Substitute: LTSpice ships IRF9640. 200 V vs IRF9540's 100 V, but
|
||
// identical pin-out and similar Vto. The `pchan` keyword is what
|
||
// tells the VDMOS engine to flip polarity.
|
||
'.model MIRF9540 VDMOS(pchan Rg=3 Vto=-3.5 Rd=.15 Rs=.15 Rb=.15 Kp=8 lambda=.01 mtriode=.5 Cgdmax=1.5n Cgdmin=.07n Cgs=1n Cjo=1n Is=38p Vds=-200 Ron=.5 Qg=44n)',
|
||
]),
|
||
};
|
||
},
|
||
// FQP27P06 has no good LTSpice equivalent — kept on Level=1 PMOS with the
|
||
// historical parameter set. Upgrade when a Fairchild VDMOS model is found.
|
||
'mosfet-fqp27p06': (comp, netLookup) => {
|
||
const d = netLookup('D');
|
||
const g = netLookup('G');
|
||
const s = netLookup('S');
|
||
if (!d || !g || !s) return null;
|
||
return {
|
||
cards: [`M_${comp.id} ${d} ${g} ${s} ${s} MFQP27P06 L=2u W=500u`],
|
||
modelsUsed: new Set(['.model MFQP27P06 PMOS(Level=1 Vto=-2.5 Kp=50u Lambda=0.01)']),
|
||
};
|
||
},
|
||
|
||
// Op-amp (behavioral VCVS — simplest macro)
|
||
'opamp-ideal': (comp, netLookup) => {
|
||
const inp = netLookup('IN+');
|
||
const inn = netLookup('IN-');
|
||
const out = netLookup('OUT');
|
||
if (!inp || !inn || !out) return null;
|
||
return {
|
||
cards: [`E_${comp.id} ${out} 0 ${inp} ${inn} 1e6`],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
|
||
// ── Real op-amp part numbers (behavioral, saturation-clamped) ─────────────
|
||
// All 4 mappers follow the same shape:
|
||
// V_out = clamp(A · (V_in+ − V_in-), Vsat_lo, Vsat_hi)
|
||
// Rails are derived from ctx.vcc. Single-supply assumption: output saturates
|
||
// between a low rail (ground + headroom) and a high rail (vcc − headroom).
|
||
// Headroom is chip-specific (LM358/LM324 are near rail-to-rail output,
|
||
// LM741 needs ~1.5 V, TL072 with JFET input needs ~2 V).
|
||
//
|
||
// Input impedance is 1 MΩ differential + a 10 MΩ common-mode load so the
|
||
// netlist never has floating inputs during DC.
|
||
'opamp-lm358': (comp, netLookup) => {
|
||
const inp = netLookup('IN+');
|
||
const inn = netLookup('IN-');
|
||
const out = netLookup('OUT');
|
||
if (!inp || !inn || !out) return null;
|
||
// Phase 1d #9: real LM358 macro-model subckt enabled now that
|
||
// Phase 1d #2 added `.options gmin=1e-10 gminsteps=20 sourcesteps=10
|
||
// method=gear maxord=2` to both adapters — the subckt converges
|
||
// where the prior `.op` skipped. Power rails wire implicitly to
|
||
// vcc_rail / 0 (the canvas doesn't draw op-amp power pins).
|
||
// Real slew rate (~0.5 V/µs), GBW (~1 MHz), and rail headroom
|
||
// come for free vs the prior behavioural B-source clamp.
|
||
return {
|
||
cards: [`X_${comp.id} ${inp} ${inn} vcc_rail 0 ${out} LM358`],
|
||
modelsUsed: new Set([LM358_SUBCKT]),
|
||
};
|
||
},
|
||
'opamp-lm741': (comp, netLookup, ctx) => {
|
||
const inp = netLookup('IN+');
|
||
const inn = netLookup('IN-');
|
||
const out = netLookup('OUT');
|
||
if (!inp || !inn || !out) return null;
|
||
const A = 2e5;
|
||
const vLo = 1.5;
|
||
const vHi = ctx.vcc - 1.5;
|
||
return {
|
||
cards: [
|
||
`R_${comp.id}_inp ${inp} 0 2Meg`,
|
||
`R_${comp.id}_inn ${inn} 0 2Meg`,
|
||
`B_${comp.id} ${out} 0 V = max(${vLo}, min(${vHi}, ${A}*(V(${inp})-V(${inn}))))`,
|
||
`R_${comp.id}_out ${out} 0 1Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
'opamp-tl072': (comp, netLookup, ctx) => {
|
||
const inp = netLookup('IN+');
|
||
const inn = netLookup('IN-');
|
||
const out = netLookup('OUT');
|
||
if (!inp || !inn || !out) return null;
|
||
const A = 2e5;
|
||
const vLo = 2.0;
|
||
const vHi = ctx.vcc - 2.0;
|
||
return {
|
||
cards: [
|
||
// JFET input: huge Z_in — model with 1 TΩ
|
||
`R_${comp.id}_inp ${inp} 0 1T`,
|
||
`R_${comp.id}_inn ${inn} 0 1T`,
|
||
`B_${comp.id} ${out} 0 V = max(${vLo}, min(${vHi}, ${A}*(V(${inp})-V(${inn}))))`,
|
||
`R_${comp.id}_out ${out} 0 1Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
'opamp-lm324': (comp, netLookup, ctx) => {
|
||
// Quad op-amp — electrically identical to LM358 per-channel
|
||
const inp = netLookup('IN+');
|
||
const inn = netLookup('IN-');
|
||
const out = netLookup('OUT');
|
||
if (!inp || !inn || !out) return null;
|
||
const A = 1e5;
|
||
const vLo = 0.05;
|
||
const vHi = ctx.vcc - 1.5;
|
||
return {
|
||
cards: [
|
||
`R_${comp.id}_inp ${inp} 0 10Meg`,
|
||
`R_${comp.id}_inn ${inn} 0 10Meg`,
|
||
`B_${comp.id} ${out} 0 V = max(${vLo}, min(${vHi}, ${A}*(V(${inp})-V(${inn}))))`,
|
||
`R_${comp.id}_out ${out} 0 1Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
|
||
// ── Linear voltage regulators ────────────────────────────────────────────
|
||
// Behavioral model: V_out = min(V_in - V_dropout, V_nom). Dropout voltage
|
||
// ≈ 2V for classic 78xx / 79xx series. When V_in is too low, V_out drops
|
||
// to (V_in − V_dropout), matching real-chip behaviour in under-voltage.
|
||
// Pin names: VIN, VOUT, GND (for 78xx) — for 79xx GND swaps to be a
|
||
// reference rail since V_out is negative.
|
||
'reg-7805': (comp, netLookup) => {
|
||
const vin = netLookup('VIN');
|
||
const gnd = netLookup('GND');
|
||
const vout = netLookup('VOUT');
|
||
if (!vin || !gnd || !vout) return null;
|
||
return {
|
||
cards: [
|
||
`B_${comp.id} ${vout} ${gnd} V = min(V(${vin})-V(${gnd})-2, 5)`,
|
||
`R_${comp.id}_out ${vout} ${gnd} 10Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
'reg-7812': (comp, netLookup) => {
|
||
const vin = netLookup('VIN');
|
||
const gnd = netLookup('GND');
|
||
const vout = netLookup('VOUT');
|
||
if (!vin || !gnd || !vout) return null;
|
||
return {
|
||
cards: [
|
||
`B_${comp.id} ${vout} ${gnd} V = min(V(${vin})-V(${gnd})-2, 12)`,
|
||
`R_${comp.id}_out ${vout} ${gnd} 10Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
'reg-7905': (comp, netLookup) => {
|
||
// 7905 delivers −5 V relative to its GND pin. V_in is negative (below GND).
|
||
const vin = netLookup('VIN');
|
||
const gnd = netLookup('GND');
|
||
const vout = netLookup('VOUT');
|
||
if (!vin || !gnd || !vout) return null;
|
||
return {
|
||
cards: [
|
||
`B_${comp.id} ${vout} ${gnd} V = max(V(${vin})-V(${gnd})+2, -5)`,
|
||
`R_${comp.id}_out ${vout} ${gnd} 10Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
// Adjustable regulator: V(VOUT) − V(ADJ) = 1.25 V (ideal). User wires
|
||
// R1 from VOUT to ADJ, R2 from ADJ to ground: V_out = 1.25·(1+R2/R1).
|
||
// The B-source references ground (not ADJ) so load current has a proper
|
||
// return path — otherwise SPICE can't close the circuit.
|
||
'reg-lm317': (comp, netLookup) => {
|
||
const vin = netLookup('VIN');
|
||
const adj = netLookup('ADJ');
|
||
const vout = netLookup('VOUT');
|
||
if (!vin || !adj || !vout) return null;
|
||
return {
|
||
cards: [
|
||
`B_${comp.id} ${vout} 0 V = V(${adj}) + min(V(${vin})-V(${adj})-2, 1.25)`,
|
||
`R_${comp.id}_out ${vout} 0 10Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
|
||
// ── Battery / DC cell sources ────────────────────────────────────────────
|
||
// Modelled as a V-source + tiny series resistance (approx. ESR). The
|
||
// positive terminal is 'VCC' / '+' and the negative is 'GND' / '−'.
|
||
'battery-9v': (comp, netLookup) => {
|
||
const pos = netLookup('+') ?? netLookup('VCC');
|
||
const neg = netLookup('−') ?? netLookup('-') ?? netLookup('GND');
|
||
if (!pos || !neg) return null;
|
||
return {
|
||
cards: [
|
||
`V_${comp.id} ${pos} ${comp.id}_int DC 9`,
|
||
`R_${comp.id}_esr ${comp.id}_int ${neg} 1.5`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
'battery-aa': (comp, netLookup) => {
|
||
const pos = netLookup('+') ?? netLookup('VCC');
|
||
const neg = netLookup('−') ?? netLookup('-') ?? netLookup('GND');
|
||
if (!pos || !neg) return null;
|
||
return {
|
||
cards: [
|
||
`V_${comp.id} ${pos} ${comp.id}_int DC 1.5`,
|
||
`R_${comp.id}_esr ${comp.id}_int ${neg} 0.15`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
'battery-coin-cell': (comp, netLookup) => {
|
||
const pos = netLookup('+') ?? netLookup('VCC');
|
||
const neg = netLookup('−') ?? netLookup('-') ?? netLookup('GND');
|
||
if (!pos || !neg) return null;
|
||
return {
|
||
cards: [
|
||
`V_${comp.id} ${pos} ${comp.id}_int DC 3`,
|
||
`R_${comp.id}_esr ${comp.id}_int ${neg} 10`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
|
||
// ── Regulated power supply (DC / AC, current-limit aware) ───────────────
|
||
// Properties:
|
||
// mode: 'dc' | 'ac' (default 'dc')
|
||
// voltage: V (default 5)
|
||
// frequency: Hz (default 50, only used in AC mode)
|
||
// currentLimit: A (default 1; enforced by circuitVerifier,
|
||
// NOT SPICE — ngspice has no native
|
||
// foldback limiter)
|
||
// The visual element reuses wokwi-signal-generator (no new Web Component
|
||
// needed). ESR is derived from the currentLimit so a near-short reads
|
||
// as I ≈ 1.5·limit, which the verifier then flags as source-overload.
|
||
'power-supply': (comp, netLookup) => {
|
||
const pos = netLookup('+') ?? netLookup('SIG') ?? netLookup('VCC');
|
||
const neg = netLookup('−') ?? netLookup('-') ?? netLookup('GND');
|
||
if (!pos || !neg) return null;
|
||
const mode = String(comp.properties.mode ?? 'dc').toLowerCase();
|
||
const voltage = Number(comp.properties.voltage ?? 5);
|
||
const currentLimit = Math.max(0.01, Number(comp.properties.currentLimit ?? 1));
|
||
const esr = Math.max(0.01, voltage / (currentLimit * 1.5));
|
||
let source: string;
|
||
if (mode === 'ac') {
|
||
const freq = Number(comp.properties.frequency ?? 50);
|
||
source = `SIN(0 ${voltage} ${freq})`;
|
||
} else {
|
||
source = `DC ${voltage}`;
|
||
}
|
||
return {
|
||
cards: [
|
||
`V_${comp.id} ${pos} ${comp.id}_int ${source}`,
|
||
`R_${comp.id}_esr ${comp.id}_int ${neg} ${esr}`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
|
||
// ── Signal generator (AC / pulse / DC) ───────────────────────────────────
|
||
// Properties:
|
||
// waveform: 'sine' | 'square' | 'dc' (default 'sine')
|
||
// frequency: Hz (default 1000)
|
||
// amplitude: V peak (default 1)
|
||
// offset: V DC (default 0)
|
||
// Emits a SIN / PULSE / DC source accordingly. For square waves uses
|
||
// PULSE with ~1ns edges; for DC, amplitude is ignored.
|
||
'signal-generator': (comp, netLookup) => {
|
||
const sig = netLookup('SIG') ?? netLookup('+');
|
||
const gnd = netLookup('GND') ?? netLookup('-') ?? netLookup('−');
|
||
if (!sig || !gnd) return null;
|
||
const waveform = String(comp.properties.waveform ?? 'sine').toLowerCase();
|
||
const freq = Number(comp.properties.frequency ?? 1000);
|
||
const amp = Number(comp.properties.amplitude ?? 1);
|
||
const off = Number(comp.properties.offset ?? 0);
|
||
let source: string;
|
||
if (waveform === 'square') {
|
||
const period = 1 / freq;
|
||
const pw = period / 2;
|
||
const vlo = off - amp;
|
||
const vhi = off + amp;
|
||
source = `PULSE(${vlo} ${vhi} 0 1n 1n ${pw} ${period})`;
|
||
} else if (waveform === 'dc') {
|
||
source = `DC ${off}`;
|
||
} else {
|
||
source = `SIN(${off} ${amp} ${freq})`;
|
||
}
|
||
return {
|
||
cards: [`V_${comp.id} ${sig} ${gnd} ${source}`],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
|
||
// Switch / pushbutton — a real 4-pin tactile switch. The two legs of each
|
||
// terminal are internally shorted (1.l is the same node as 1.r; 2.l as 2.r),
|
||
// and pressing bridges terminal 1 to terminal 2. Modelling BOTH legs (not
|
||
// just 1.l/2.l) means a user can wire GND/GPIO to any leg and it behaves like
|
||
// hardware — and wiring both a GPIO and GND to the SAME terminal is a dead
|
||
// short, exactly as on a real button. Back-compat: 2-pin variants expose A/B.
|
||
pushbutton: (comp, netLookup) => {
|
||
const t1l = netLookup('1.l');
|
||
const t1r = netLookup('1.r');
|
||
const t2l = netLookup('2.l');
|
||
const t2r = netLookup('2.r');
|
||
const cards: string[] = [];
|
||
// Internal shorts between the two legs of a terminal, when both are wired.
|
||
if (t1l && t1r && t1l !== t1r) cards.push(`R_${comp.id}_t1 ${t1l} ${t1r} 0.01`);
|
||
if (t2l && t2r && t2l !== t2r) cards.push(`R_${comp.id}_t2 ${t2l} ${t2r} 0.01`);
|
||
// The switch itself: terminal 1 to terminal 2 (prefer the .l leg's net).
|
||
const term1 = t1l ?? t1r ?? netLookup('A');
|
||
const term2 = t2l ?? t2r ?? netLookup('B');
|
||
if (term1 && term2) {
|
||
const R = Boolean(comp.properties.pressed) ? 0.01 : 1e9;
|
||
cards.push(`R_${comp.id}_sw ${term1} ${term2} ${R}`);
|
||
}
|
||
return cards.length ? { cards, modelsUsed: new Set() } : null;
|
||
},
|
||
'slide-switch': (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, '1', '2');
|
||
if (!pins) return null;
|
||
const closed = comp.properties.value === 1 || comp.properties.value === '1';
|
||
return emitResistor(comp, pins, closed ? 0.01 : 1e9);
|
||
},
|
||
|
||
// Rotary potentiometer — 3-terminal divider. `value` lives in [min..max]
|
||
// (defaults match the wokwi-potentiometer element: 0..1023). Total track
|
||
// resistance defaults to 10 kΩ; callers can override via `properties.total`.
|
||
potentiometer: (comp, netLookup) => {
|
||
const top = netLookup('VCC');
|
||
const wiper = netLookup('SIG');
|
||
const bot = netLookup('GND');
|
||
if (!top || !wiper || !bot) return null;
|
||
const total = parseValueWithUnits(comp.properties.total ?? comp.properties.resistance, 10_000);
|
||
const min = Number(comp.properties.min ?? 0);
|
||
const max = Number(comp.properties.max ?? 1023);
|
||
const raw = Number(comp.properties.value ?? min);
|
||
const span = max - min || 1;
|
||
const ratio = Math.max(0, Math.min(1, (raw - min) / span));
|
||
const Rtop = Math.max(1, (1 - ratio) * total);
|
||
const Rbot = Math.max(1, ratio * total);
|
||
return {
|
||
cards: [
|
||
`R_${comp.id}_top ${top} ${wiper} ${Rtop}`,
|
||
`R_${comp.id}_bot ${wiper} ${bot} ${Rbot}`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
|
||
// Slide potentiometer (3-terminal voltage divider)
|
||
'slide-potentiometer': (comp, netLookup) => {
|
||
const top = netLookup('VCC');
|
||
const wiper = netLookup('SIG');
|
||
const bot = netLookup('GND');
|
||
if (!top || !wiper || !bot) return null;
|
||
const total = parseValueWithUnits(comp.properties.value, 10_000);
|
||
const pos = Number(comp.properties.position ?? comp.properties.percent ?? 50) / 100;
|
||
const Rtop = Math.max(1, (1 - pos) * total);
|
||
const Rbot = Math.max(1, pos * total);
|
||
return {
|
||
cards: [
|
||
`R_${comp.id}_top ${top} ${wiper} ${Rtop}`,
|
||
`R_${comp.id}_bot ${wiper} ${bot} ${Rbot}`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
|
||
// NTC temperature sensor — 3-pin breakout module (VCC, GND, OUT).
|
||
// Internal topology: a 10k pull-up from VCC to OUT, with the NTC thermistor
|
||
// from OUT to GND, so V_OUT = Vcc · R_ntc / (R_ntc + R_pull). Temperature up
|
||
// → R_ntc down → V_OUT down. This is the exact divider the ntc-temperature
|
||
// example sketch inverts to recover R_ntc (rNtc = R_PULL · v / (5 − v)); with
|
||
// VCC and GND swapped (NTC on top) the decoded temperature ran backwards.
|
||
'ntc-temperature-sensor': (comp, netLookup) => {
|
||
const vcc = netLookup('VCC');
|
||
const gnd = netLookup('GND');
|
||
const out = netLookup('OUT');
|
||
const Tc = Number(comp.properties.temperature ?? 25);
|
||
const R0 = parseValueWithUnits(comp.properties.R0, 10_000);
|
||
const beta = Number(comp.properties.beta ?? 3950);
|
||
const Rntc = ntcResistance(Tc, R0, 298.15, beta);
|
||
if (!vcc || !gnd || !out) {
|
||
// Fallback for legacy 2-pin wiring ('1' / '2'): emit bare thermistor.
|
||
const pins = twoPin(comp, netLookup, '1', '2');
|
||
if (!pins) return null;
|
||
return emitResistor(comp, pins, Rntc);
|
||
}
|
||
const Rpull = parseValueWithUnits(comp.properties.pullup, 10_000);
|
||
return {
|
||
cards: [`R_${comp.id}_pull ${vcc} ${out} ${Rpull}`, `R_${comp.id}_ntc ${out} ${gnd} ${Rntc}`],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
|
||
// Ammeter — inserts a 0 V source so ngspice reports the branch current.
|
||
// Terminals are 'A+' and 'A-'. The probe is modelled as:
|
||
// A+ ──[V_<id>_sense=0]── mid ──[shunt=1mΩ]── A-
|
||
// The tiny shunt is there only to ensure the mid node has a DC path if
|
||
// one of the terminals is otherwise floating.
|
||
'instr-ammeter': (comp, netLookup) => {
|
||
const ap = netLookup('A+');
|
||
const am = netLookup('A-');
|
||
if (!ap || !am) return null;
|
||
const senseName = `v_${comp.id}_sense`;
|
||
const midNet = `amm_${comp.id}_mid`;
|
||
return {
|
||
cards: [`V_${comp.id}_sense ${ap} ${midNet} DC 0`, `R_${comp.id}_shunt ${midNet} ${am} 1m`],
|
||
modelsUsed: new Set([`* ammeter probe: read i(${senseName})`]),
|
||
};
|
||
},
|
||
|
||
// Voltmeter — pure probe. Emits a 10 MΩ resistor across its terminals so
|
||
// ngspice has a real element there (and so the net isn't floating).
|
||
'instr-voltmeter': (comp, netLookup) => {
|
||
const vp = netLookup('V+');
|
||
const vm = netLookup('V-');
|
||
if (!vp || !vm) return null;
|
||
return {
|
||
cards: [`R_${comp.id}_vmR ${vp} ${vm} 10Meg`],
|
||
modelsUsed: new Set([`* voltmeter probe: read v(${vp}) - v(${vm})`]),
|
||
};
|
||
},
|
||
|
||
// ── Digital logic gates (behavioral, via ngspice B-sources) ────────────
|
||
// Convention: inputs at 2-input gates are 'A','B'; output is 'Y'. Threshold
|
||
// is ctx.vcc/2. A 1 MΩ load keeps the output node DC-connected so ngspice
|
||
// doesn't see it as floating (otherwise .op returns matrix singular).
|
||
'logic-gate-and': (comp, netLookup, ctx) => {
|
||
const a = netLookup('A'),
|
||
b = netLookup('B'),
|
||
y = netLookup('Y');
|
||
if (!a || !b || !y) return null;
|
||
const T = ctx.vcc / 2;
|
||
return {
|
||
cards: [
|
||
`B_${comp.id} ${y} 0 V = ${ctx.vcc} * u(V(${a})-${T}) * u(V(${b})-${T})`,
|
||
`R_${comp.id}_load ${y} 0 1Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
'logic-gate-or': (comp, netLookup, ctx) => {
|
||
const a = netLookup('A'),
|
||
b = netLookup('B'),
|
||
y = netLookup('Y');
|
||
if (!a || !b || !y) return null;
|
||
const T = ctx.vcc / 2;
|
||
return {
|
||
cards: [
|
||
`B_${comp.id} ${y} 0 V = ${ctx.vcc} * (1 - (1-u(V(${a})-${T})) * (1-u(V(${b})-${T})))`,
|
||
`R_${comp.id}_load ${y} 0 1Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
'logic-gate-nand': (comp, netLookup, ctx) => {
|
||
const a = netLookup('A'),
|
||
b = netLookup('B'),
|
||
y = netLookup('Y');
|
||
if (!a || !b || !y) return null;
|
||
const T = ctx.vcc / 2;
|
||
return {
|
||
cards: [
|
||
`B_${comp.id} ${y} 0 V = ${ctx.vcc} * (1 - u(V(${a})-${T}) * u(V(${b})-${T}))`,
|
||
`R_${comp.id}_load ${y} 0 1Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
'logic-gate-nor': (comp, netLookup, ctx) => {
|
||
const a = netLookup('A'),
|
||
b = netLookup('B'),
|
||
y = netLookup('Y');
|
||
if (!a || !b || !y) return null;
|
||
const T = ctx.vcc / 2;
|
||
return {
|
||
cards: [
|
||
`B_${comp.id} ${y} 0 V = ${ctx.vcc} * (1-u(V(${a})-${T})) * (1-u(V(${b})-${T}))`,
|
||
`R_${comp.id}_load ${y} 0 1Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
'logic-gate-xor': (comp, netLookup, ctx) => {
|
||
const a = netLookup('A'),
|
||
b = netLookup('B'),
|
||
y = netLookup('Y');
|
||
if (!a || !b || !y) return null;
|
||
const T = ctx.vcc / 2;
|
||
return {
|
||
cards: [
|
||
`B_${comp.id} ${y} 0 V = ${ctx.vcc} * (u(V(${a})-${T}) + u(V(${b})-${T}) - 2*u(V(${a})-${T})*u(V(${b})-${T}))`,
|
||
`R_${comp.id}_load ${y} 0 1Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
'logic-gate-xnor': (comp, netLookup, ctx) => {
|
||
const a = netLookup('A'),
|
||
b = netLookup('B'),
|
||
y = netLookup('Y');
|
||
if (!a || !b || !y) return null;
|
||
const T = ctx.vcc / 2;
|
||
return {
|
||
cards: [
|
||
`B_${comp.id} ${y} 0 V = ${ctx.vcc} * (1 - (u(V(${a})-${T}) + u(V(${b})-${T}) - 2*u(V(${a})-${T})*u(V(${b})-${T})))`,
|
||
`R_${comp.id}_load ${y} 0 1Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
'logic-gate-not': (comp, netLookup, ctx) => {
|
||
const a = netLookup('A'),
|
||
y = netLookup('Y');
|
||
if (!a || !y) return null;
|
||
const T = ctx.vcc / 2;
|
||
return {
|
||
cards: [
|
||
`B_${comp.id} ${y} 0 V = ${ctx.vcc} * (1 - u(V(${a})-${T}))`,
|
||
`R_${comp.id}_load ${y} 0 1Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
},
|
||
|
||
// ── Multi-input logic gates (3 / 4 inputs) ──────────────────────────────
|
||
// Build the u() product/sum across all inputs. Same 1 MΩ load convention.
|
||
...(() => {
|
||
type MultiMapper = (
|
||
comp: ComponentForSpice,
|
||
netLookup: NetLookup,
|
||
ctx: MapperContext,
|
||
) => SpiceEmission | null;
|
||
function multiGate(
|
||
inputNames: string[],
|
||
build: (inputs: string[], T: number, vcc: number) => string,
|
||
): MultiMapper {
|
||
return (comp, netLookup, ctx) => {
|
||
const inputs = inputNames.map((n) => netLookup(n));
|
||
const y = netLookup('Y');
|
||
if (inputs.some((n) => !n) || !y) return null;
|
||
const T = ctx.vcc / 2;
|
||
return {
|
||
cards: [
|
||
`B_${comp.id} ${y} 0 V = ${build(inputs as string[], T, ctx.vcc)}`,
|
||
`R_${comp.id}_load ${y} 0 1Meg`,
|
||
],
|
||
modelsUsed: new Set(),
|
||
};
|
||
};
|
||
}
|
||
const andExpr = (inputs: string[], T: number, vcc: number) =>
|
||
`${vcc} * ${inputs.map((n) => `u(V(${n})-${T})`).join(' * ')}`;
|
||
const orExpr = (inputs: string[], T: number, vcc: number) =>
|
||
`${vcc} * (1 - ${inputs.map((n) => `(1-u(V(${n})-${T}))`).join(' * ')})`;
|
||
const nandExpr = (inputs: string[], T: number, vcc: number) =>
|
||
`${vcc} * (1 - ${inputs.map((n) => `u(V(${n})-${T})`).join(' * ')})`;
|
||
const norExpr = (inputs: string[], T: number, vcc: number) =>
|
||
`${vcc} * ${inputs.map((n) => `(1-u(V(${n})-${T}))`).join(' * ')}`;
|
||
return {
|
||
'logic-gate-and-3': multiGate(['A', 'B', 'C'], andExpr),
|
||
'logic-gate-or-3': multiGate(['A', 'B', 'C'], orExpr),
|
||
'logic-gate-nand-3': multiGate(['A', 'B', 'C'], nandExpr),
|
||
'logic-gate-nor-3': multiGate(['A', 'B', 'C'], norExpr),
|
||
'logic-gate-and-4': multiGate(['A', 'B', 'C', 'D'], andExpr),
|
||
'logic-gate-or-4': multiGate(['A', 'B', 'C', 'D'], orExpr),
|
||
'logic-gate-nand-4': multiGate(['A', 'B', 'C', 'D'], nandExpr),
|
||
'logic-gate-nor-4': multiGate(['A', 'B', 'C', 'D'], norExpr),
|
||
};
|
||
})(),
|
||
|
||
// ── L293D dual H-bridge motor driver ────────────────────────────────────
|
||
// 16-pin package with two independent channels. Per channel:
|
||
// EN, IN1, IN2, OUT1, OUT2 + shared VCC1 (logic) and VCC2 (motor)
|
||
//
|
||
// Truth table per output:
|
||
// EN=LOW → output high-Z (modeled as weak 10MΩ pull to GND)
|
||
// EN=HIGH → OUT follows IN: HIGH → V_motor, LOW → 0
|
||
//
|
||
// Uses ctx.vcc as the logic threshold reference; V_motor is taken from the
|
||
// VCC2 net if wired, otherwise defaults to ctx.vcc. The behavioural output
|
||
// linearly drives OUT via a B-source + load resistor for DC path.
|
||
'motor-driver-l293d': (comp, netLookup, ctx) => {
|
||
const T = ctx.vcc / 2;
|
||
const vcc2 = netLookup('VCC2') ?? netLookup('+VS') ?? netLookup('VS');
|
||
const vMotorExpr = vcc2 ? `V(${vcc2})` : `${ctx.vcc}`;
|
||
const cards: string[] = [];
|
||
|
||
for (const ch of [1, 2]) {
|
||
const en = netLookup(`EN${ch}`);
|
||
const in1 = netLookup(`IN${2 * ch - 1}`);
|
||
const in2 = netLookup(`IN${2 * ch}`);
|
||
const out1 = netLookup(`OUT${2 * ch - 1}`);
|
||
const out2 = netLookup(`OUT${2 * ch}`);
|
||
if (!en) continue;
|
||
if (in1 && out1) {
|
||
// OUT1 = u(EN-T) * (u(IN1-T) * V_motor)
|
||
cards.push(
|
||
`B_${comp.id}_ch${ch}a ${out1} 0 V = u(V(${en})-${T}) * u(V(${in1})-${T}) * ${vMotorExpr}`,
|
||
);
|
||
cards.push(`R_${comp.id}_ch${ch}a_load ${out1} 0 10Meg`);
|
||
}
|
||
if (in2 && out2) {
|
||
cards.push(
|
||
`B_${comp.id}_ch${ch}b ${out2} 0 V = u(V(${en})-${T}) * u(V(${in2})-${T}) * ${vMotorExpr}`,
|
||
);
|
||
cards.push(`R_${comp.id}_ch${ch}b_load ${out2} 0 10Meg`);
|
||
}
|
||
}
|
||
|
||
if (cards.length === 0) return null;
|
||
return { cards, modelsUsed: new Set() };
|
||
},
|
||
|
||
// ── 74HC series logic ICs (multiple gates per package) ──────────────────
|
||
// Each IC emits one B-source + pull-down per internal gate. Pin naming
|
||
// follows the datasheet: gate index prefixes (1A, 1B, 1Y, 2A, ...) plus
|
||
// VCC / GND for the package power (not used by the behavioral gates here —
|
||
// they reference ctx.vcc directly).
|
||
...(() => {
|
||
type MultiMapper = (
|
||
comp: ComponentForSpice,
|
||
netLookup: NetLookup,
|
||
ctx: MapperContext,
|
||
) => SpiceEmission | null;
|
||
|
||
function ic2InputQuad(
|
||
build: (a: string, b: string, y: string, T: number, vcc: number) => string,
|
||
): MultiMapper {
|
||
return (comp, netLookup, ctx) => {
|
||
const T = ctx.vcc / 2;
|
||
const cards: string[] = [];
|
||
for (let i = 1; i <= 4; i++) {
|
||
const a = netLookup(`${i}A`);
|
||
const b = netLookup(`${i}B`);
|
||
const y = netLookup(`${i}Y`);
|
||
if (!a || !b || !y) continue; // skip unwired gates silently
|
||
cards.push(`B_${comp.id}_${i} ${y} 0 V = ${build(a, b, y, T, ctx.vcc)}`);
|
||
cards.push(`R_${comp.id}_${i}_load ${y} 0 1Meg`);
|
||
}
|
||
if (cards.length === 0) return null;
|
||
return { cards, modelsUsed: new Set() };
|
||
};
|
||
}
|
||
|
||
function ic1InputHex(
|
||
build: (a: string, y: string, T: number, vcc: number) => string,
|
||
): MultiMapper {
|
||
return (comp, netLookup, ctx) => {
|
||
const T = ctx.vcc / 2;
|
||
const cards: string[] = [];
|
||
for (let i = 1; i <= 6; i++) {
|
||
const a = netLookup(`${i}A`);
|
||
const y = netLookup(`${i}Y`);
|
||
if (!a || !y) continue;
|
||
cards.push(`B_${comp.id}_${i} ${y} 0 V = ${build(a, y, T, ctx.vcc)}`);
|
||
cards.push(`R_${comp.id}_${i}_load ${y} 0 1Meg`);
|
||
}
|
||
if (cards.length === 0) return null;
|
||
return { cards, modelsUsed: new Set() };
|
||
};
|
||
}
|
||
|
||
return {
|
||
// 74HC00 — quad 2-input NAND
|
||
'ic-74hc00': ic2InputQuad(
|
||
(a, b, _y, T, vcc) => `${vcc} * (1 - u(V(${a})-${T}) * u(V(${b})-${T}))`,
|
||
),
|
||
// 74HC08 — quad 2-input AND
|
||
'ic-74hc08': ic2InputQuad((a, b, _y, T, vcc) => `${vcc} * u(V(${a})-${T}) * u(V(${b})-${T})`),
|
||
// 74HC32 — quad 2-input OR
|
||
'ic-74hc32': ic2InputQuad(
|
||
(a, b, _y, T, vcc) => `${vcc} * (1 - (1-u(V(${a})-${T})) * (1-u(V(${b})-${T})))`,
|
||
),
|
||
// 74HC02 — quad 2-input NOR
|
||
'ic-74hc02': ic2InputQuad(
|
||
(a, b, _y, T, vcc) => `${vcc} * (1-u(V(${a})-${T})) * (1-u(V(${b})-${T}))`,
|
||
),
|
||
// 74HC86 — quad 2-input XOR
|
||
'ic-74hc86': ic2InputQuad(
|
||
(a, b, _y, T, vcc) =>
|
||
`${vcc} * (u(V(${a})-${T}) + u(V(${b})-${T}) - 2*u(V(${a})-${T})*u(V(${b})-${T}))`,
|
||
),
|
||
// 74HC04 — hex inverter
|
||
'ic-74hc04': ic1InputHex((a, _y, T, vcc) => `${vcc} * (1 - u(V(${a})-${T}))`),
|
||
// 74HC14 — hex Schmitt-trigger inverter. Threshold moves based on
|
||
// current output state: high output → lower trip (0.4·Vcc), low output
|
||
// → higher trip (0.6·Vcc). This is the hysteresis band.
|
||
'ic-74hc14': ic1InputHex((a, y, _T, vcc) => {
|
||
const hi = 0.6 * vcc;
|
||
const lo = 0.4 * vcc;
|
||
return `${vcc} * (1 - u(V(${a}) - (${hi} - u(V(${y})-${vcc / 2}) * ${hi - lo})))`;
|
||
}),
|
||
};
|
||
})(),
|
||
|
||
// ── Optocouplers (LED + phototransistor in one package) ─────────────────
|
||
// Pattern: LED on input side, current-sense resistor (0V source) measures
|
||
// I_LED, an F-source (CCCS) mirrors that current into the phototransistor
|
||
// output with the part's Current Transfer Ratio (CTR).
|
||
'opto-4n25': (comp, netLookup) => {
|
||
const an = netLookup('AN');
|
||
const cat = netLookup('CAT');
|
||
const col = netLookup('COL');
|
||
const emit = netLookup('EMIT');
|
||
if (!an || !cat || !col || !emit) return null;
|
||
const CTR = 0.5; // 50%
|
||
return {
|
||
cards: [
|
||
`D_${comp.id}_led ${an} ${comp.id}_mid DLED_OPTO`,
|
||
`V_${comp.id}_sense ${comp.id}_mid ${cat} DC 0`,
|
||
`F_${comp.id}_pt ${col} ${emit} V_${comp.id}_sense ${CTR}`,
|
||
`R_${comp.id}_leak ${col} ${emit} 100Meg`,
|
||
],
|
||
modelsUsed: new Set(['.model DLED_OPTO D(Is=1e-14 N=2 Rs=5)']),
|
||
};
|
||
},
|
||
'opto-pc817': (comp, netLookup) => {
|
||
const an = netLookup('AN');
|
||
const cat = netLookup('CAT');
|
||
const col = netLookup('COL');
|
||
const emit = netLookup('EMIT');
|
||
if (!an || !cat || !col || !emit) return null;
|
||
const CTR = 1.0; // 100% (typical for PC817, min 50% max 600%)
|
||
return {
|
||
cards: [
|
||
`D_${comp.id}_led ${an} ${comp.id}_mid DLED_OPTO`,
|
||
`V_${comp.id}_sense ${comp.id}_mid ${cat} DC 0`,
|
||
`F_${comp.id}_pt ${col} ${emit} V_${comp.id}_sense ${CTR}`,
|
||
`R_${comp.id}_leak ${col} ${emit} 100Meg`,
|
||
],
|
||
modelsUsed: new Set(['.model DLED_OPTO D(Is=1e-14 N=2 Rs=5)']),
|
||
};
|
||
},
|
||
|
||
// ── Electromechanical relay (SPDT, 5-pin) ───────────────────────────────
|
||
// Coil is modelled as R + L in parallel. Contacts are voltage-controlled
|
||
// switches (ngspice `S` element) with native hysteresis via Vt/Vh — avoids
|
||
// chatter when V_COIL sits near the activation threshold. The NC contact
|
||
// uses a B-source that inverts the coil voltage as its control signal,
|
||
// because ngspice SW has no "normally closed" mode.
|
||
// Optional flyback diode across the coil (anode on COIL-, cathode on COIL+).
|
||
// NO and NC contact cards are only emitted when their respective pins are
|
||
// wired — leaving NC unconnected is a very common pattern and must not
|
||
// suppress the rest of the relay (coil + NO switch).
|
||
relay: (comp, netLookup) => {
|
||
const cp = netLookup('COIL+');
|
||
const cn = netLookup('COIL-');
|
||
const com = netLookup('COM');
|
||
const no = netLookup('NO');
|
||
const nc = netLookup('NC');
|
||
// Coil pins must be present — without them the relay can't be energised.
|
||
// COM is required too; without it, neither NO nor NC contact is useful.
|
||
if (!cp || !cn || !com) return null;
|
||
const coilR = Number(comp.properties.coil_resistance ?? 70);
|
||
const coilV = Number(comp.properties.coil_voltage ?? 5);
|
||
const threshold = coilV * 0.6; // drop-in at 60% of nominal
|
||
const hysteresis = coilV * 0.15;
|
||
const includeFlyback = comp.properties.include_flyback !== false;
|
||
// A relay coil is a wire-wound inductor: R (of the copper) in SERIES
|
||
// with ideal L. Modelling R and L in parallel would make the coil a DC
|
||
// short — V(COIL+) ≡ V(COIL-) in .op analysis — so the switch control
|
||
// voltage is always 0 and the NO contact never closes.
|
||
const coilMidNet = `${comp.id}_coilmid`;
|
||
const cards = [
|
||
`R_${comp.id}_coil ${cp} ${coilMidNet} ${coilR}`,
|
||
`L_${comp.id}_coil ${coilMidNet} ${cn} 20m`,
|
||
];
|
||
if (no) {
|
||
// NO: closes when V_coil > Vt (normal SW behaviour)
|
||
cards.push(`S_${comp.id}_no ${com} ${no} ${cp} ${cn} RELAY_SW`);
|
||
}
|
||
if (nc) {
|
||
// NC: inverted control — B-source maps (V_coil → Vnom − V_coil) so that
|
||
// SW still "turns on when ctrl > Vt", but meaning is inverted.
|
||
const ctrlInvNet = `${comp.id}_ncctrl`;
|
||
cards.push(`B_${comp.id}_ncctrl ${ctrlInvNet} 0 V = ${coilV} - (V(${cp}) - V(${cn}))`);
|
||
cards.push(`S_${comp.id}_nc ${com} ${nc} ${ctrlInvNet} 0 RELAY_SW`);
|
||
}
|
||
if (includeFlyback) {
|
||
cards.push(`D_${comp.id}_fly ${cn} ${cp} D1N4148`);
|
||
}
|
||
return {
|
||
cards,
|
||
modelsUsed: new Set([
|
||
`.model RELAY_SW SW(Vt=${threshold} Vh=${hysteresis} Ron=0.05 Roff=1G)`,
|
||
// Must match the canonical D1N4148 in `diode-1n4148` exactly, or
|
||
// the netlist dedupe Set will emit two `.model D1N4148` lines.
|
||
'.model D1N4148 D(Is=2.52n Rs=.568 N=1.752 Cjo=4p M=.4 tt=20n Bv=100 Ibv=0.1u)',
|
||
]),
|
||
};
|
||
},
|
||
|
||
// ── Schottky diodes (Vf ≈ 0.3–0.45 V at 1 A). Phase 2: LTSpice models
|
||
// sourced from OnSemi via Linear Tech standard.dio.
|
||
'diode-1n5817': (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, 'A', 'C');
|
||
if (!pins) return null;
|
||
return {
|
||
cards: [`D_${comp.id} ${pins[0]} ${pins[1]} D1N5817`],
|
||
modelsUsed: new Set([
|
||
'.model D1N5817 D(Is=31.7u Rs=.051 N=1.373 Cjo=190p M=.3 Eg=.69 Xti=2 Bv=20 Ibv=10m)',
|
||
]),
|
||
};
|
||
},
|
||
'diode-1n5819': (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, 'A', 'C');
|
||
if (!pins) return null;
|
||
return {
|
||
cards: [`D_${comp.id} ${pins[0]} ${pins[1]} D1N5819`],
|
||
modelsUsed: new Set([
|
||
'.model D1N5819 D(Is=31.7u Rs=.051 N=1.373 Cjo=110p M=.35 Eg=.69 Xti=2 Bv=40 Ibv=10m)',
|
||
]),
|
||
};
|
||
},
|
||
|
||
// ── Photodiode (reverse-biased, current proportional to lux) ─────────────
|
||
// Model: regular diode in parallel with a current source that sinks photo-
|
||
// current from cathode to anode (reverse direction). Typical responsivity:
|
||
// 100 nA/lux for small-package silicon. User sets `lux` property.
|
||
photodiode: (comp, netLookup) => {
|
||
const pins = twoPin(comp, netLookup, 'A', 'C');
|
||
if (!pins) return null;
|
||
const lux = Number(comp.properties.lux ?? 500);
|
||
const iph = lux * 100e-9; // 100 nA/lux
|
||
return {
|
||
cards: [
|
||
`D_${comp.id} ${pins[0]} ${pins[1]} DPHOTO`,
|
||
`I_${comp.id}_ph ${pins[1]} ${pins[0]} DC ${iph}`,
|
||
],
|
||
modelsUsed: new Set(['.model DPHOTO D(Is=10p N=1.1 Rs=10)']),
|
||
};
|
||
},
|
||
|
||
// Photoresistor (R(lux) = R_dark / (1 + k·lux))
|
||
// Photoresistor sensor — 4-pin breakout module (VCC, GND, DO, AO).
|
||
// Internal topology: LDR between VCC and AO, plus an internal 10k pull-down
|
||
// from AO to GND. Brighter light → LDR resistance drops → V_AO rises.
|
||
// The DO (digital threshold) pin is ignored for analog simulation.
|
||
photoresistor: (comp, netLookup) => {
|
||
const lux = Number(comp.properties.lux ?? 500);
|
||
const Rdark = parseValueWithUnits(comp.properties.dark, 1_000_000);
|
||
const k = Number(comp.properties.k ?? 5);
|
||
const Rldr = Rdark / (1 + (k * lux) / 1000);
|
||
const vcc = netLookup('VCC');
|
||
const gnd = netLookup('GND');
|
||
const ao = netLookup('AO');
|
||
if (vcc && gnd && ao) {
|
||
const Rpull = parseValueWithUnits(comp.properties.pullup, 10_000);
|
||
return {
|
||
cards: [`R_${comp.id}_ldr ${vcc} ${ao} ${Rldr}`, `R_${comp.id}_pull ${ao} ${gnd} ${Rpull}`],
|
||
modelsUsed: new Set(),
|
||
};
|
||
}
|
||
// Legacy / discrete LDR fallbacks: emit bare 2-terminal resistor.
|
||
const pins = twoPin(comp, netLookup, 'LDR1', 'LDR2') ?? twoPin(comp, netLookup, '1', '2');
|
||
if (!pins) return null;
|
||
return emitResistor(comp, pins, Rldr);
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Preset variants of the generic passive parts. Each preset shares the
|
||
* same web-component tag and the same SPICE emit logic as its base; the
|
||
* picker uses different `defaultValues.value` per entry so users get
|
||
* common values one click away (e.g. drop in a "Resistor 1kΩ" instead of
|
||
* having to type "1000" into the property dialog).
|
||
*
|
||
* Exported so storeAdapter and DynamicComponent can keep their meta-id
|
||
* tables in sync without restating the list.
|
||
*/
|
||
export const PASSIVE_PRESETS: Readonly<
|
||
Record<string, 'resistor' | 'capacitor' | 'capacitor-electrolytic' | 'inductor'>
|
||
> = {
|
||
// Resistors — E12 series subset covering the common Arduino-bench picks
|
||
'resistor-220': 'resistor',
|
||
'resistor-330': 'resistor',
|
||
'resistor-470': 'resistor',
|
||
'resistor-1k': 'resistor',
|
||
'resistor-2k2': 'resistor',
|
||
'resistor-4k7': 'resistor',
|
||
'resistor-10k': 'resistor',
|
||
'resistor-22k': 'resistor',
|
||
'resistor-47k': 'resistor',
|
||
'resistor-100k': 'resistor',
|
||
'resistor-1m': 'resistor',
|
||
// Ceramic caps (non-polarized, smaller end of the range)
|
||
'cap-10p': 'capacitor',
|
||
'cap-22p': 'capacitor',
|
||
'cap-100p': 'capacitor',
|
||
'cap-1n': 'capacitor',
|
||
'cap-10n': 'capacitor',
|
||
'cap-100n': 'capacitor',
|
||
'cap-1u': 'capacitor',
|
||
// Electrolytic caps (polarized, larger values)
|
||
'cap-elec-1u': 'capacitor-electrolytic',
|
||
'cap-elec-10u': 'capacitor-electrolytic',
|
||
'cap-elec-47u': 'capacitor-electrolytic',
|
||
'cap-elec-100u': 'capacitor-electrolytic',
|
||
'cap-elec-470u': 'capacitor-electrolytic',
|
||
'cap-elec-1000u': 'capacitor-electrolytic',
|
||
// Inductors
|
||
'ind-100u': 'inductor',
|
||
'ind-1m': 'inductor',
|
||
'ind-10m': 'inductor',
|
||
};
|
||
|
||
// Wire each preset to its base mapper.
|
||
for (const [presetId, baseId] of Object.entries(PASSIVE_PRESETS)) {
|
||
MAPPERS[presetId] = MAPPERS[baseId];
|
||
}
|
||
|
||
// Alias: metadata id for the wokwi-photoresistor-sensor breakout is
|
||
// `photoresistor-sensor`, but the mapper is registered under the short
|
||
// name `photoresistor` (matches the bare LDR/discrete element). Without
|
||
// this alias, any example that drops a photoresistor sensor on the
|
||
// canvas gets a null mapping → no R_ldr / R_pull emitted → A0 net
|
||
// floats → analogRead returns 0 even though the divider should solve.
|
||
MAPPERS['photoresistor-sensor'] = MAPPERS['photoresistor'];
|
||
|
||
/**
|
||
* Public entry: map one Velxio component to SPICE cards.
|
||
* Returns null if we have no mapping for this metadataId (caller should
|
||
* skip the component gracefully — it just won't participate in the solve).
|
||
*/
|
||
export function componentToSpice(
|
||
comp: ComponentForSpice,
|
||
netLookup: NetLookup,
|
||
ctx: MapperContext,
|
||
): SpiceEmission | null {
|
||
const mapper = MAPPERS[comp.metadataId];
|
||
if (!mapper) return null;
|
||
return mapper(comp, netLookup, ctx);
|
||
}
|
||
|
||
/** True if we have a mapping for this metadataId. */
|
||
export function isSpiceMapped(metadataId: string): boolean {
|
||
return metadataId in MAPPERS;
|
||
}
|
||
|
||
/** All metadataIds with a SPICE mapping (for docs / UI hints). */
|
||
export function mappedMetadataIds(): string[] {
|
||
return Object.keys(MAPPERS);
|
||
}
|